2
0
NGPixel 5 жил өмнө
parent
commit
62d1d7a1df

+ 3 - 2
client/components/admin/admin-groups-edit-permissions.vue

@@ -24,6 +24,7 @@
             v-card-text.pt-0
               template(v-for='(pm, idx) in pmGroup.items')
                 v-checkbox.pt-0(
+                  style='justify-content: space-between;'
                   :key='pm.permission'
                   :label='pm.permission'
                   :hint='pm.hint'
@@ -60,14 +61,14 @@ export default {
             },
             {
               permission: 'write:pages',
-              hint: 'Can create new pages, as specified in the Page Rules',
+              hint: 'Can create / edit pages, as specified in the Page Rules',
               warning: false,
               restrictedForSystem: false,
               disabled: false
             },
             {
               permission: 'manage:pages',
-              hint: 'Can edit and move existing pages as specified in the Page Rules',
+              hint: 'Can move existing pages as specified in the Page Rules',
               warning: false,
               restrictedForSystem: false,
               disabled: false

+ 1 - 1
client/components/admin/admin-pages-edit.vue

@@ -105,7 +105,7 @@
             v-icon(left) mdi-code-tags
             span Source
           v-divider.mx-2(vertical)
-          v-btn(color='primary', text, :href='`/h/` + page.locale + `/` + page.path')
+          v-btn(color='primary', text, :href='`/h/` + page.locale + `/` + page.path', disabled)
             v-icon(left) mdi-history
             span History
           v-spacer

+ 33 - 1
client/components/admin/admin-utilities-content.vue

@@ -3,6 +3,12 @@
     v-toolbar(flat, color='primary', dark, dense)
       .subtitle-1 {{ $t('admin:utilities.contentTitle') }}
     v-card-text
+      .subtitle-1.pb-3.primary--text Rebuild Page Tree
+      .body-2 The virtual structure of your wiki is automatically inferred from all page paths. You can trigger a full rebuild of the tree if some virtual folders are missing or not valid anymore.
+      v-btn(outlined, color='primary', @click='rebuildTree', :disabled='loading').ml-0.mt-3
+        v-icon(left) mdi-gesture-double-tap
+        span Proceed
+      v-divider.my-5
       .subtitle-1.pb-3.pl-0.primary--text Migrate all pages to target locale
       .body-2 If you created content before selecting a different locale and activating the namespacing capabilities, you may want to transfer all content to the base locale.
       .body-2.red--text: strong This operation is destructive and cannot be reversed! Make sure you have proper backups!
@@ -35,6 +41,7 @@
 <script>
 import _ from 'lodash'
 import utilityContentMigrateLocaleMutation from 'gql/admin/utilities/utilities-mutation-content-migratelocale.gql'
+import utilityContentRebuildTreeMutation from 'gql/admin/utilities/utilities-mutation-content-rebuildtree.gql'
 
 /* global siteLangs, siteConfig */
 
@@ -55,7 +62,32 @@ export default {
     }
   },
   methods: {
-    async migrateToLocale() {
+    async rebuildTree () {
+      this.loading = true
+      this.$store.commit(`loadingStart`, 'admin-utilities-content-rebuildtree')
+
+      try {
+        const respRaw = await this.$apollo.mutate({
+          mutation: utilityContentRebuildTreeMutation
+        })
+        const resp = _.get(respRaw, 'data.pages.rebuildTree.responseResult', {})
+        if (resp.succeeded) {
+          this.$store.commit('showNotification', {
+            message: 'Page Tree rebuilt successfully.',
+            style: 'success',
+            icon: 'check'
+          })
+        } else {
+          throw new Error(resp.message)
+        }
+      } catch (err) {
+        this.$store.commit('pushGraphError', err)
+      }
+
+      this.$store.commit(`loadingStop`, 'admin-utilities-content-rebuildtree')
+      this.loading = false
+    },
+    async migrateToLocale () {
       this.loading = true
       this.$store.commit(`loadingStart`, 'admin-utilities-content-migratelocale')
 

+ 22 - 2
client/components/common/nav-header.vue

@@ -204,6 +204,8 @@ import { get, sync } from 'vuex-pathify'
 import _ from 'lodash'
 import Cookies from 'js-cookie'
 
+import movePageMutation from 'gql/common/common-pages-mutation-move.gql'
+
 /* global siteLangs */
 
 export default {
@@ -342,8 +344,26 @@ export default {
     pageMove () {
       this.movePageModal = true
     },
-    pageMoveRename ({ path, locale }) {
-
+    async pageMoveRename ({ path, locale }) {
+      this.$store.commit(`loadingStart`, 'page-move')
+      try {
+        const resp = await this.$apollo.mutate({
+          mutation: movePageMutation,
+          variables: {
+            id: this.$store.get('page/id'),
+            destinationLocale: locale,
+            destinationPath: path
+          }
+        })
+        if (_.get(resp, 'data.pages.move.responseResult.succeeded', false)) {
+          window.location.replace(`/${locale}/${path}`)
+        } else {
+          throw new Error(_.get(resp, 'data.pages.move.responseResult.message', this.$t('common:error.unexpected')))
+        }
+      } catch (err) {
+        this.$store.commit('pushGraphError', err)
+        this.$store.commit(`loadingStop`, 'page-move')
+      }
     },
     pageDelete () {
       this.deletePageModal = true

+ 18 - 1
client/components/common/page-selector.vue

@@ -98,6 +98,8 @@ import _ from 'lodash'
 import { get } from 'vuex-pathify'
 import pageTreeQuery from 'gql/common/common-pages-query-tree.gql'
 
+const localeSegmentRegex = /^[A-Z]{2}(-[A-Z]{2})?$/i
+
 /* global siteLangs, siteConfig */
 
 export default {
@@ -152,7 +154,22 @@ export default {
       return _.sortBy(_.filter(this.pages, ['parent', _.head(this.currentNode) || 0]), ['title', 'path'])
     },
     isValidPath () {
-      return this.currentPath && this.currentPath.length > 2
+      if (!this.currentPath) {
+        return false
+      }
+      const firstSection = _.head(this.currentPath.split('/'))
+      if (firstSection.length <= 1) {
+        return false
+      } else if (localeSegmentRegex.test(firstSection)) {
+        return false
+      } else if (
+        _.some(['login', 'logout', 'register', 'verify', 'favicons', 'fonts', 'img', 'js', 'svg'], p => {
+          return p === firstSection
+        })) {
+        return false
+      } else {
+        return true
+      }
     }
   },
   watch: {

+ 5 - 0
client/components/editor.vue

@@ -249,6 +249,11 @@ export default {
               style: 'success',
               icon: 'check'
             })
+            if (this.locale !== this.$store.get('page/locale') || this.path !== this.$store.get('page/path')) {
+              _.delay(() => {
+                window.location.replace(`/e/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
+              }, 1000)
+            }
           } else {
             throw new Error(_.get(resp, 'responseResult.message'))
           }

+ 5 - 4
client/components/editor/editor-modal-properties.vue

@@ -52,7 +52,6 @@
                     :items='namespaces'
                     v-model='locale'
                     hide-details
-                    :disabled='mode !== "create"'
                   )
                 v-flex(xs12, md10)
                   v-text-field(
@@ -63,7 +62,6 @@
                     :hint='$t(`editor:props.pathHint`)'
                     persistent-hint
                     @click:append='showPathSelector'
-                    :disabled='mode !== "create"'
                     )
           v-divider
           v-card-text.grey.pt-5(:class='darkMode ? `darken-3-d5` : `lighten-4`')
@@ -219,7 +217,7 @@
               inset
               )
 
-    page-selector(mode='create', v-model='pageSelectorShown', :path='path', :locale='locale', :open-handler='setPath')
+    page-selector(:mode='pageSelectorMode', v-model='pageSelectorShown', :path='path', :locale='locale', :open-handler='setPath')
 </template>
 
 <script>
@@ -257,7 +255,10 @@ export default {
     path: sync('page/path'),
     isPublished: sync('page/isPublished'),
     publishStartDate: sync('page/publishStartDate'),
-    publishEndDate: sync('page/publishEndDate')
+    publishEndDate: sync('page/publishEndDate'),
+    pageSelectorMode () {
+      return (this.mode === 'create') ? 'create' : 'move'
+    }
   },
   watch: {
     value(newValue, oldValue) {

+ 12 - 0
client/graph/admin/utilities/utilities-mutation-content-rebuildtree.gql

@@ -0,0 +1,12 @@
+mutation {
+  pages {
+    rebuildTree {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+    }
+  }
+}

+ 12 - 0
client/graph/common/common-pages-mutation-move.gql

@@ -0,0 +1,12 @@
+mutation($id: Int!, $destinationPath: String!, $destinationLocale: String!) {
+  pages {
+    move(id: $id, destinationPath: $destinationPath, destinationLocale: $destinationLocale) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+    }
+  }
+}

+ 17 - 24
client/themes/default/scss/app.scss

@@ -226,7 +226,7 @@
   }
 
   blockquote {
-    padding: 0 0 1rem 0;
+    padding: 0 0 1rem 1rem;
     border-left: 5px solid mc('blue', '500');
     border-radius: .5rem;
     margin: 1rem 0;
@@ -242,9 +242,8 @@
 
     &.is-info {
       background-color: mc('blue', '50');
-      background-image: radial-gradient(ellipse at top, mc('blue', '50'), lighten(mc('blue', '50'), 5%));
-      border-color: mc('blue', '100');
-      box-shadow: 0 0 2px 0 mc('blue', '100');
+      border-color: mc('blue', '300');
+      color: mc('blue', '900');
 
       code {
         background-color: mc('blue', '50');
@@ -252,17 +251,15 @@
       }
 
       @at-root .theme--dark & {
-        background-color: mc('grey', '900');
-        background-image: radial-gradient(ellipse at top, rgba(mc('blue', '900'), .25), rgba(darken(mc('blue', '900'), 5%), .2));
+        background-color: mc('blue', '900');
+        color: mc('blue', '50');
         border-color: mc('blue', '500');
-        box-shadow: 0 0 2px 0 mc('grey', '900');
       }
     }
     &.is-warning {
       background-color: mc('orange', '50');
-      background-image: radial-gradient(ellipse at top, mc('orange', '50'), lighten(mc('orange', '50'), 5%));
-      border-color: mc('orange', '100');
-      box-shadow: 0 0 2px 0 mc('orange', '100');
+      border-color: mc('orange', '300');
+      color: darken(mc('orange', '900'), 10%);
 
       code {
         background-color: mc('orange', '50');
@@ -270,17 +267,16 @@
       }
 
       @at-root .theme--dark & {
-        background-color: mc('grey', '900');
-        background-image: radial-gradient(ellipse at top, rgba(mc('orange', '900'), .25), rgba(darken(mc('orange', '900'), 5%), .2));
+        background-color: darken(mc('orange', '900'), 5%);
+        color: mc('orange', '100');
         border-color: mc('orange', '500');
         box-shadow: 0 0 2px 0 mc('grey', '900');
       }
     }
     &.is-danger {
       background-color: mc('red', '50');
-      background-image: radial-gradient(ellipse at top, mc('red', '50'), lighten(mc('red', '50'), 5%));
-      border-color: mc('red', '100');
-      box-shadow: 0 0 2px 0 mc('red', '100');
+      border-color: mc('red', '300');
+      color: mc('red', '900');
 
       code {
         background-color: mc('red', '50');
@@ -288,17 +284,15 @@
       }
 
       @at-root .theme--dark & {
-        background-color: mc('grey', '900');
-        background-image: radial-gradient(ellipse at top, rgba(mc('red', '900'), .1), rgba(darken(mc('red', '900'), 5%), .2));
+        background-color: mc('red', '900');
+        color: mc('red', '100');
         border-color: mc('red', '500');
-        box-shadow: 0 0 2px 0 mc('grey', '900');
       }
     }
     &.is-success {
       background-color: mc('green', '50');
-      background-image: radial-gradient(ellipse at top, mc('green', '50'), lighten(mc('green', '50'), 5%));
-      border-color: mc('green', '100');
-      box-shadow: 0 0 2px 0 mc('green', '100');
+      border-color: mc('green', '300');
+      color: mc('green', '900');
 
       code {
         background-color: mc('green', '50');
@@ -306,10 +300,9 @@
       }
 
       @at-root .theme--dark & {
-        background-color: mc('grey', '900');
-        background-image: radial-gradient(ellipse at top, rgba(mc('green', '900'), .4), rgba(darken(mc('green', '900'), 5%), .2));
+        background-color: mc('green', '900');
+        color: mc('green', '50');
         border-color: mc('green', '500');
-        box-shadow: 0 0 2px 0 mc('grey', '900');
       }
     }
   }

+ 1 - 1
server/core/auth.js

@@ -165,7 +165,7 @@ module.exports = {
     }
 
     // Check Page Rules
-    if (path && user.groups) {
+    if (page && user.groups) {
       let checkState = {
         deny: false,
         match: false,

+ 101 - 22
server/graph/resolvers/page.js

@@ -11,6 +11,9 @@ module.exports = {
     async pages() { return {} }
   },
   PageQuery: {
+    /**
+     * PAGE HISTORY
+     */
     async history(obj, args, context, info) {
       return WIKI.models.pageHistory.getHistory({
         pageId: args.id,
@@ -18,6 +21,9 @@ module.exports = {
         offsetSize: args.offsetSize || 100
       })
     },
+    /**
+     * SEARCH PAGES
+     */
     async search (obj, args, context) {
       if (WIKI.data.searchEngine) {
         const resp = await WIKI.data.searchEngine.query(args.query, args)
@@ -38,6 +44,9 @@ module.exports = {
         }
       }
     },
+    /**
+     * LIST PAGES
+     */
     async list (obj, args, context, info) {
       let results = await WIKI.models.pages.query().column([
         'pages.id',
@@ -101,6 +110,9 @@ module.exports = {
       }
       return results
     },
+    /**
+     * FETCH SINGLE PAGE
+     */
     async single (obj, args, context, info) {
       let page = await WIKI.models.pages.getPageFromDb(args.id)
       if (page) {
@@ -113,9 +125,15 @@ module.exports = {
         throw new WIKI.Error.PageNotFound()
       }
     },
+    /**
+     * FETCH TAGS
+     */
     async tags (obj, args, context, info) {
       return WIKI.models.tags.query().orderBy('tag', 'asc')
     },
+    /**
+     * FETCH PAGE TREE
+     */
     async tree (obj, args, context, info) {
       let results = []
       let conds = {
@@ -147,35 +165,75 @@ module.exports = {
     }
   },
   PageMutation: {
+    /**
+     * CREATE PAGE
+     */
     async create(obj, args, context) {
-      const page = await WIKI.models.pages.createPage({
-        ...args,
-        authorId: context.req.user.id
-      })
-      return {
-        responseResult: graphHelper.generateSuccess('Page created successfully.'),
-        page
+      try {
+        const page = await WIKI.models.pages.createPage({
+          ...args,
+          user: context.req.user
+        })
+        return {
+          responseResult: graphHelper.generateSuccess('Page created successfully.'),
+          page
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
       }
     },
-    async delete(obj, args, context) {
-      await WIKI.models.pages.deletePage({
-        ...args,
-        authorId: context.req.user.id
-      })
-      return {
-        responseResult: graphHelper.generateSuccess('Page has been deleted.')
+    /**
+     * UPDATE PAGE
+     */
+    async update(obj, args, context) {
+      try {
+        const page = await WIKI.models.pages.updatePage({
+          ...args,
+          user: context.req.user
+        })
+        return {
+          responseResult: graphHelper.generateSuccess('Page has been updated.'),
+          page
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
       }
     },
-    async update(obj, args, context) {
-      const page = await WIKI.models.pages.updatePage({
-        ...args,
-        authorId: context.req.user.id
-      })
-      return {
-        responseResult: graphHelper.generateSuccess('Page has been updated.'),
-        page
+    /**
+     * MOVE PAGE
+     */
+    async move(obj, args, context) {
+      try {
+        await WIKI.models.pages.movePage({
+          ...args,
+          user: context.req.user
+        })
+        return {
+          responseResult: graphHelper.generateSuccess('Page has been moved.')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
+    /**
+     * DELETE PAGE
+     */
+    async delete(obj, args, context) {
+      try {
+        await WIKI.models.pages.deletePage({
+          ...args,
+          user: context.req.user
+        })
+        return {
+          responseResult: graphHelper.generateSuccess('Page has been deleted.')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
       }
     },
+    /**
+     * FLUSH PAGE CACHE
+     */
     async flushCache(obj, args, context) {
       try {
         await WIKI.models.pages.flushCache()
@@ -186,6 +244,9 @@ module.exports = {
         return graphHelper.generateError(err)
       }
     },
+    /**
+     * MIGRATE ALL PAGES FROM SOURCE LOCALE TO TARGET LOCALE
+     */
     async migrateToLocale(obj, args, context) {
       try {
         const count = await WIKI.models.pages.migrateToLocale(args)
@@ -196,6 +257,24 @@ module.exports = {
       } catch (err) {
         return graphHelper.generateError(err)
       }
+    },
+    /**
+     * REBUILD TREE
+     */
+    async rebuildTree(obj, args, context) {
+      try {
+        const rebuildJob = await WIKI.scheduler.registerJob({
+          name: 'rebuild-tree',
+          immediate: true,
+          worker: true
+        })
+        await rebuildJob.finished
+        return {
+          responseResult: graphHelper.generateSuccess('Page tree rebuilt successfully.')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
     }
   },
   Page: {

+ 9 - 1
server/graph/schemas/page.graphql

@@ -80,7 +80,13 @@ type PageMutation {
     publishStartDate: Date
     tags: [String]
     title: String
-  ): PageResponse @auth(requires: ["manage:pages", "manage:system"])
+  ): PageResponse @auth(requires: ["write:pages", "manage:pages", "manage:system"])
+
+  move(
+    id: Int!
+    destinationPath: String!
+    destinationLocale: String!
+  ): DefaultResponse @auth(requires: ["manage:pages", "manage:system"])
 
   delete(
     id: Int!
@@ -92,6 +98,8 @@ type PageMutation {
     sourceLocale: String!
     targetLocale: String!
   ): PageMigrationResponse @auth(requires: ["manage:system"])
+
+  rebuildTree: DefaultResponse @auth(requires: ["manage:system"])
 }
 
 # -----------------------------------------------

+ 20 - 0
server/helpers/error.js

@@ -117,6 +117,14 @@ module.exports = {
     message: 'Mail template failed to load.',
     code: 3003
   }),
+  PageCreateForbidden: CustomError('PageCreateForbidden', {
+    message: 'You are not authorized to create this page.',
+    code: 6008
+  }),
+  PageDeleteForbidden: CustomError('PageDeleteForbidden', {
+    message: 'You are not authorized to delete this page.',
+    code: 6010
+  }),
   PageGenericError: CustomError('PageGenericError', {
     message: 'An unexpected error occured during a page operation.',
     code: 6001
@@ -133,10 +141,22 @@ module.exports = {
     message: 'Page path cannot contains illegal characters.',
     code: 6005
   }),
+  PageMoveForbidden: CustomError('PageMoveForbidden', {
+    message: 'You are not authorized to move this page.',
+    code: 6007
+  }),
   PageNotFound: CustomError('PageNotFound', {
     message: 'This page does not exist.',
     code: 6003
   }),
+  PagePathCollision: CustomError('PagePathCollision', {
+    message: 'Destination page path already exists.',
+    code: 6006
+  }),
+  PageUpdateForbidden: CustomError('PageUpdateForbidden', {
+    message: 'You are not authorized to update this page.',
+    code: 6009
+  }),
   SearchActivationFailed: CustomError('SearchActivationFailed', {
     message: 'Search Engine activation failed.',
     code: 4002

+ 139 - 5
server/models/pages.js

@@ -213,23 +213,35 @@ module.exports = class Page extends Model {
    * @returns {Promise} Promise of the Page Model Instance
    */
   static async createPage(opts) {
+    // -> Validate path
     if (opts.path.indexOf('.') >= 0 || opts.path.indexOf(' ') >= 0) {
       throw new WIKI.Error.PageIllegalPath()
     }
 
+    // -> Check for page access
+    if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
+      locale: opts.locale,
+      path: opts.path
+    })) {
+      throw new WIKI.Error.PageDeleteForbidden()
+    }
+
+    // -> Check for duplicate
     const dupCheck = await WIKI.models.pages.query().select('id').where('localeCode', opts.locale).where('path', opts.path).first()
     if (dupCheck) {
       throw new WIKI.Error.PageDuplicateCreate()
     }
 
+    // -> Check for empty content
     if (!opts.content || _.trim(opts.content).length < 1) {
       throw new WIKI.Error.PageEmptyContent()
     }
 
+    // -> Create page
     await WIKI.models.pages.query().insert({
-      authorId: opts.authorId,
+      authorId: opts.user.id,
       content: opts.content,
-      creatorId: opts.authorId,
+      creatorId: opts.user.id,
       contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
       description: opts.description,
       editorKey: opts.editor,
@@ -246,7 +258,7 @@ module.exports = class Page extends Model {
     const page = await WIKI.models.pages.getPageFromDb({
       path: opts.path,
       locale: opts.locale,
-      userId: opts.authorId,
+      userId: opts.user.id,
       isPrivate: opts.isPrivate
     })
 
@@ -288,22 +300,35 @@ module.exports = class Page extends Model {
    * @returns {Promise} Promise of the Page Model Instance
    */
   static async updatePage(opts) {
+    // -> Fetch original page
     const ogPage = await WIKI.models.pages.query().findById(opts.id)
     if (!ogPage) {
       throw new Error('Invalid Page Id')
     }
 
+    // -> Check for page access
+    if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
+      locale: opts.locale,
+      path: opts.path
+    })) {
+      throw new WIKI.Error.PageUpdateForbidden()
+    }
+
+    // -> Check for empty content
     if (!opts.content || _.trim(opts.content).length < 1) {
       throw new WIKI.Error.PageEmptyContent()
     }
 
+    // -> Create version snapshot
     await WIKI.models.pageHistory.addVersion({
       ...ogPage,
       isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
       action: 'updated'
     })
+
+    // -> Update page
     await WIKI.models.pages.query().patch({
-      authorId: opts.authorId,
+      authorId: opts.user.id,
       content: opts.content,
       description: opts.description,
       isPublished: opts.isPublished === true || opts.isPublished === 1,
@@ -311,7 +336,7 @@ module.exports = class Page extends Model {
       publishStartDate: opts.publishStartDate || '',
       title: opts.title
     }).where('id', ogPage.id)
-    const page = await WIKI.models.pages.getPageFromDb({
+    let page = await WIKI.models.pages.getPageFromDb({
       path: ogPage.path,
       locale: ogPage.localeCode,
       userId: ogPage.authorId,
@@ -336,9 +361,106 @@ module.exports = class Page extends Model {
         page
       })
     }
+
+    // -> Perform move?
+    if (opts.locale !== page.localeCode || opts.path !== page.path) {
+      await WIKI.models.pages.movePage({
+        id: page.id,
+        destinationLocale: opts.locale,
+        destinationPath: opts.path,
+        user: opts.user
+      })
+    }
+
     return page
   }
 
+  /**
+   * Move a Page
+   *
+   * @param {Object} opts Page Properties
+   * @returns {Promise} Promise with no value
+   */
+  static async movePage(opts) {
+    const page = await WIKI.models.pages.query().findById(opts.id)
+    if (!page) {
+      throw new WIKI.Error.PageNotFound()
+    }
+
+    // -> Check for source page access
+    if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
+      locale: page.sourceLocale,
+      path: page.sourcePath
+    })) {
+      throw new WIKI.Error.PageMoveForbidden()
+    }
+    // -> Check for destination page access
+    if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
+      locale: opts.destinationLocale,
+      path: opts.destinationPath
+    })) {
+      throw new WIKI.Error.PageMoveForbidden()
+    }
+
+    // -> Check for existing page at destination path
+    const destPage = await await WIKI.models.pages.query().findOne({
+      path: opts.destinationPath,
+      localeCode: opts.destinationLocale
+    })
+    if (destPage) {
+      throw new WIKI.Error.PagePathCollision()
+    }
+
+    // -> Create version snapshot
+    await WIKI.models.pageHistory.addVersion({
+      ...page,
+      action: 'moved'
+    })
+
+    const destinationHash = pageHelper.generateHash({ path: opts.destinationPath, locale: opts.destinationLocale, privateNS: opts.isPrivate ? 'TODO' : '' })
+
+    // -> Move page
+    await WIKI.models.pages.query().patch({
+      path: opts.destinationPath,
+      localeCode: opts.destinationLocale,
+      hash: destinationHash
+    }).findById(page.id)
+    await WIKI.models.pages.deletePageFromCache(page)
+
+    // -> Rename in Search Index
+    await WIKI.data.searchEngine.renamed({
+      ...page,
+      destinationPath: opts.destinationPath,
+      destinationLocaleCode: opts.destinationLocale,
+      destinationHash
+    })
+
+    // -> Rename in Storage
+    if (!opts.skipStorage) {
+      await WIKI.models.storage.pageEvent({
+        event: 'renamed',
+        page: {
+          ...page,
+          destinationPath: opts.destinationPath,
+          destinationLocaleCode: opts.destinationLocale,
+          destinationHash,
+          moveAuthorId: opts.user.id,
+          moveAuthorName: opts.user.name,
+          moveAuthorEmail: opts.user.email
+        }
+      })
+    }
+
+    // -> Reconnect Links
+    await WIKI.models.pages.reconnectLinks({
+      sourceLocale: page.localeCode,
+      sourcePath: page.path,
+      locale: opts.destinationLocale,
+      path: opts.destinationPath,
+      mode: 'move'
+    })
+  }
+
   /**
    * Delete an Existing Page
    *
@@ -358,10 +480,22 @@ module.exports = class Page extends Model {
     if (!page) {
       throw new Error('Invalid Page Id')
     }
+
+    // -> Check for page access
+    if (!WIKI.auth.checkAccess(opts.user, ['delete:pages'], {
+      locale: page.locale,
+      path: page.path
+    })) {
+      throw new WIKI.Error.PageDeleteForbidden()
+    }
+
+    // -> Create version snapshot
     await WIKI.models.pageHistory.addVersion({
       ...page,
       action: 'deleted'
     })
+
+    // -> Delete page
     await WIKI.models.pages.query().delete().where('id', page.id)
     await WIKI.models.pages.deletePageFromCache(page)
 

+ 2 - 2
server/modules/search/algolia/engine.js

@@ -109,10 +109,10 @@ module.exports = {
    * @param {Object} page Page to rename
    */
   async renamed(page) {
-    await this.index.deleteObject(page.sourceHash)
+    await this.index.deleteObject(page.hash)
     await this.index.addObject({
       objectID: page.destinationHash,
-      locale: page.localeCode,
+      locale: page.destinationLocaleCode,
       path: page.destinationPath,
       title: page.title,
       description: page.description,

+ 2 - 2
server/modules/search/aws/engine.js

@@ -255,7 +255,7 @@ module.exports = {
       documents: JSON.stringify([
         {
           type: 'delete',
-          id: page.sourceHash
+          id: page.hash
         }
       ])
     }).promise()
@@ -266,7 +266,7 @@ module.exports = {
           type: 'add',
           id: page.destinationHash,
           fields: {
-            locale: page.localeCode,
+            locale: page.destinationLocaleCode,
             path: page.destinationPath,
             title: page.title,
             description: page.description,

+ 2 - 2
server/modules/search/azure/engine.js

@@ -191,13 +191,13 @@ module.exports = {
     await this.client.indexes.use(this.config.indexName).index([
       {
         '@search.action': 'delete',
-        id: page.sourceHash
+        id: page.hash
       }
     ])
     await this.client.indexes.use(this.config.indexName).index([
       {
         id: page.destinationHash,
-        locale: page.localeCode,
+        locale: page.destinationLocaleCode,
         path: page.destinationPath,
         title: page.title,
         description: page.description,

+ 2 - 2
server/modules/search/elasticsearch/engine.js

@@ -210,7 +210,7 @@ module.exports = {
     await this.client.delete({
       index: this.config.indexName,
       type: '_doc',
-      id: page.sourceHash,
+      id: page.hash,
       refresh: true
     })
     await this.client.index({
@@ -219,7 +219,7 @@ module.exports = {
       id: page.destinationHash,
       body: {
         suggest: this.buildSuggest(page),
-        locale: page.localeCode,
+        locale: page.destinationLocaleCode,
         path: page.destinationPath,
         title: page.title,
         description: page.description,

+ 1 - 1
server/modules/search/postgres/engine.js

@@ -129,7 +129,7 @@ module.exports = {
       locale: page.localeCode,
       path: page.sourcePath
     }).update({
-      locale: page.localeCode,
+      locale: page.destinationLocaleCode,
       path: page.destinationPath
     })
   },

+ 15 - 10
server/modules/storage/disk/storage.js

@@ -44,7 +44,7 @@ module.exports = {
   },
   async created(page) {
     WIKI.logger.info(`(STORAGE/DISK) Creating file ${page.path}...`)
-    let fileName = `${page.path}.${page.getFileExtension()}`
+    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
     if (WIKI.config.lang.code !== page.localeCode) {
       fileName = `${page.localeCode}/${fileName}`
     }
@@ -53,7 +53,7 @@ module.exports = {
   },
   async updated(page) {
     WIKI.logger.info(`(STORAGE/DISK) Updating file ${page.path}...`)
-    let fileName = `${page.path}.${page.getFileExtension()}`
+    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
     if (WIKI.config.lang.code !== page.localeCode) {
       fileName = `${page.localeCode}/${fileName}`
     }
@@ -62,7 +62,7 @@ module.exports = {
   },
   async deleted(page) {
     WIKI.logger.info(`(STORAGE/DISK) Deleting file ${page.path}...`)
-    let fileName = `${page.path}.${page.getFileExtension()}`
+    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
     if (WIKI.config.lang.code !== page.localeCode) {
       fileName = `${page.localeCode}/${fileName}`
     }
@@ -70,14 +70,19 @@ module.exports = {
     await fs.unlink(filePath)
   },
   async renamed(page) {
-    WIKI.logger.info(`(STORAGE/DISK) Renaming file ${page.sourcePath} to ${page.destinationPath}...`)
-    let sourceFilePath = `${page.sourcePath}.${page.getFileExtension()}`
-    let destinationFilePath = `${page.destinationPath}.${page.getFileExtension()}`
+    WIKI.logger.info(`(STORAGE/DISK) Renaming file ${page.path} to ${page.destinationPath}...`)
+    let sourceFilePath = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
+    let destinationFilePath = `${page.destinationPath}.${pageHelper.getFileExtension(page.contentType)}`
 
-    if (WIKI.config.lang.code !== page.localeCode) {
-      sourceFilePath = `${page.localeCode}/${sourceFilePath}`
-      destinationFilePath = `${page.localeCode}/${destinationFilePath}`
+    if (WIKI.config.lang.namespacing) {
+      if (WIKI.config.lang.code !== page.localeCode) {
+        sourceFilePath = `${page.localeCode}/${sourceFilePath}`
+      }
+      if (WIKI.config.lang.code !== page.destinationLocaleCode) {
+        destinationFilePath = `${page.destinationLocaleCode}/${destinationFilePath}`
+      }
     }
+
     await fs.move(path.join(this.config.path, sourceFilePath), path.join(this.config.path, destinationFilePath), { overwrite: true })
   },
 
@@ -93,7 +98,7 @@ module.exports = {
       new stream.Transform({
         objectMode: true,
         transform: async (page, enc, cb) => {
-          let fileName = `${page.path}.${page.getFileExtension()}`
+          let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
           if (WIKI.config.lang.code !== page.localeCode) {
             fileName = `${page.localeCode}/${fileName}`
           }

+ 15 - 11
server/modules/storage/git/storage.js

@@ -247,7 +247,7 @@ module.exports = {
    */
   async created(page) {
     WIKI.logger.info(`(STORAGE/GIT) Committing new file ${page.path}...`)
-    let fileName = `${page.path}.${page.getFileExtension()}`
+    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
     if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
       fileName = `${page.localeCode}/${fileName}`
     }
@@ -266,7 +266,7 @@ module.exports = {
    */
   async updated(page) {
     WIKI.logger.info(`(STORAGE/GIT) Committing updated file ${page.path}...`)
-    let fileName = `${page.path}.${page.getFileExtension()}`
+    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
     if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
       fileName = `${page.localeCode}/${fileName}`
     }
@@ -285,7 +285,7 @@ module.exports = {
    */
   async deleted(page) {
     WIKI.logger.info(`(STORAGE/GIT) Committing removed file ${page.path}...`)
-    let fileName = `${page.path}.${page.getFileExtension()}`
+    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
     if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
       fileName = `${page.localeCode}/${fileName}`
     }
@@ -301,18 +301,22 @@ module.exports = {
    * @param {Object} page Page to rename
    */
   async renamed(page) {
-    WIKI.logger.info(`(STORAGE/GIT) Committing file move from ${page.sourcePath} to ${page.destinationPath}...`)
-    let sourceFilePath = `${page.sourcePath}.${page.getFileExtension()}`
-    let destinationFilePath = `${page.destinationPath}.${page.getFileExtension()}`
+    WIKI.logger.info(`(STORAGE/GIT) Committing file move from ${page.path} to ${page.destinationPath}...`)
+    let sourceFilePath = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
+    let destinationFilePath = `${page.destinationPath}.${pageHelper.getFileExtension(page.contentType)}`
 
-    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
-      sourceFilePath = `${page.localeCode}/${sourceFilePath}`
-      destinationFilePath = `${page.localeCode}/${destinationFilePath}`
+    if (WIKI.config.lang.namespacing) {
+      if (WIKI.config.lang.code !== page.localeCode) {
+        sourceFilePath = `${page.localeCode}/${sourceFilePath}`
+      }
+      if (WIKI.config.lang.code !== page.destinationLocaleCode) {
+        destinationFilePath = `${page.destinationLocaleCode}/${destinationFilePath}`
+      }
     }
 
     await this.git.mv(`./${sourceFilePath}`, `./${destinationFilePath}`)
-    await this.git.commit(`docs: rename ${page.sourcePath} to ${destinationFilePath}`, destinationFilePath, {
-      '--author': `"${page.authorName} <${page.authorEmail}>"`
+    await this.git.commit(`docs: rename ${page.path} to ${page.destinationPath}`, destinationFilePath, {
+      '--author': `"${page.moveAuthorName} <${page.moveAuthorEmail}>"`
     })
   },
   /**

+ 13 - 4
server/modules/storage/s3/common.js

@@ -1,4 +1,5 @@
 const S3 = require('aws-sdk/clients/s3')
+const pageHelper = require('../../../helpers/page.js')
 
 /* global WIKI */
 
@@ -6,7 +7,7 @@ const S3 = require('aws-sdk/clients/s3')
  * Deduce the file path given the `page` object and the object's key to the page's path.
  */
 const getFilePath = (page, pathKey) => {
-  const fileName = `${page[pathKey]}.${page.getFileExtension()}`
+  const fileName = `${page[pathKey]}.${pageHelper.getFileExtension(page.contentType)}`
   const withLocaleCode = WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode
   return withLocaleCode ? `${page.localeCode}/${fileName}` : fileName
 }
@@ -55,9 +56,17 @@ module.exports = class S3CompatibleStorage {
     await this.s3.deleteObject({ Key: filePath }).promise()
   }
   async renamed(page) {
-    WIKI.logger.info(`(STORAGE/${this.storageName}) Renaming file ${page.sourcePath} to ${page.destinationPath}...`)
-    const sourceFilePath = getFilePath(page, 'sourcePath')
-    const destinationFilePath = getFilePath(page, 'destinationPath')
+    WIKI.logger.info(`(STORAGE/${this.storageName}) Renaming file ${page.path} to ${page.destinationPath}...`)
+    let sourceFilePath = `${page.path}.${page.getFileExtension()}`
+    let destinationFilePath = `${page.destinationPath}.${page.getFileExtension()}`
+    if (WIKI.config.lang.namespacing) {
+      if (WIKI.config.lang.code !== page.localeCode) {
+        sourceFilePath = `${page.localeCode}/${sourceFilePath}`
+      }
+      if (WIKI.config.lang.code !== page.destinationLocaleCode) {
+        destinationFilePath = `${page.destinationLocaleCode}/${destinationFilePath}`
+      }
+    }
     await this.s3.copyObject({ CopySource: sourceFilePath, Key: destinationFilePath }).promise()
     await this.s3.deleteObject({ Key: sourceFilePath }).promise()
   }