Bläddra i källkod

feat: image upload + list root assets (wip)

Nick 6 år sedan
förälder
incheckning
6b886b6e3f

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-v10.15.1
+v10.15.3

+ 1 - 5
client/components/common/nav-header.vue

@@ -304,11 +304,7 @@ export default {
       this.deletePageModal = true
     },
     assets () {
-      this.$store.commit('showNotification', {
-        style: 'indigo',
-        message: `Coming soon...`,
-        icon: 'directions_boat'
-      })
+      window.location.assign(`/f`)
     },
     logout () {
       Cookies.remove('jwt')

+ 139 - 32
client/components/editor/editor-modal-media.vue

@@ -11,46 +11,82 @@
                 v-btn.ml-3.my-0.radius-7(outline, large, color='teal', disabled)
                   v-icon(left) keyboard_arrow_up
                   span Parent Folder
-                v-btn.my-0.radius-7(outline, large, color='teal')
+                v-btn.my-0.mr-0.radius-7(outline, large, color='teal')
                   v-icon(left) add
                   span New Folder
               v-list.mt-3(dense, two-line)
-                template(v-for='(item, idx) of [1,2,3,4,5,6,7,8,9,10]')
+                template(v-for='(asset, idx) of assets')
                   v-list-tile(@click='')
                     v-list-tile-avatar
                       v-avatar.radius-7(color='teal')
                         v-icon(dark) image
                     v-list-tile-content
-                      v-list-tile-title Image {{item}}
-                      v-list-tile-sub-title 1024x768, 10 KBs
+                      v-list-tile-title {{asset.filename}}
+                      v-list-tile-sub-title 1024x768
                     v-list-tile-action
-                      .caption.pr-3 2019-04-07
+                      .caption {{asset.updatedAt | moment('from')}}
+                    v-divider.mx-3(vertical)
+                    v-list-tile-action(style='flex-basis: 80px;')
+                      .caption {{asset.fileSize | prettyBytes}}
+                    v-divider.mx-3(vertical)
+                    v-list-tile-action(style='flex-basis: 60px;')
+                      v-chip.teal--text(label, small, color='teal lighten-5') {{asset.ext.toUpperCase().substring(1)}}
                     v-list-tile-action
-                      v-chip.teal--text(label, small, color='teal lighten-5') JPG
-                  v-divider(v-if='idx < 10 - 1')
+                      v-menu(offset-x)
+                        v-btn(icon, slot='activator')
+                          v-icon(color='grey darken-2') more_horiz
+                        v-list.py-0
+                          v-list-tile
+                            v-list-tile-avatar
+                              v-icon(color='teal') short_text
+                            v-list-tile-content Properties
+                          v-divider
+                          v-list-tile
+                            v-list-tile-avatar
+                              v-icon(color='indigo') crop_rotate
+                            v-list-tile-content Edit
+                          v-divider
+                          v-list-tile
+                            v-list-tile-avatar
+                              v-icon(color='blue') keyboard
+                            v-list-tile-content Rename / Move
+                          v-divider
+                          v-list-tile
+                            v-list-tile-avatar
+                              v-icon(color='red') delete
+                            v-list-tile-content Delete
+                  v-divider(v-if='idx < assets.length - 1')
               .d-flex.mt-3
                 v-toolbar.radius-7(flat, color='grey lighten-4', dense, height='44')
-                  .body-2 / universe
+                  .body-2 / #[em root]
                   v-spacer
                   .body-1.grey--text.text--darken-1 10 files
-                v-btn.ml-3.my-0.radius-7(color='teal', large, @click='insert', disabled)
+                v-btn.ml-3.mr-0.my-0.radius-7(color='teal', large, @click='insert', disabled)
                   v-icon(left) save_alt
                   span Insert
 
         v-flex(xs3)
           v-card.radius-7.animated.fadeInRight.wait-p3s(light)
             v-card-text
-              v-toolbar.radius-7(color='teal lighten-5', dense, flat)
-                v-icon.mr-3(color='teal') cloud_upload
-                .body-2.teal--text Upload Images
+              .d-flex
+                v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
+                  v-icon.mr-3(color='teal') cloud_upload
+                  .body-2.teal--text Upload Images
+                v-btn.my-0.ml-3.mr-0.radius-7(outline, large, color='teal', @click='browse')
+                  v-icon(left) touch_app
+                  span Browse
               file-pond.mt-3(
                 name='mediaUpload'
                 ref='pond'
                 label-idle='Browse or Drop files here...'
                 allow-multiple='true'
-                accepted-file-types='image/jpeg, image/png, image/gif, image/svg'
+                :accepted-file-types='[`image/jpeg`, `image/png`, `image/gif`, `image/svg`]'
                 :files='files'
                 max-files='10'
+                server='/u'
+                :instant-upload='false'
+                :allow-revert='false'
+                @processfile='onFileProcessed'
               )
             v-divider
             v-card-actions.pa-3
@@ -92,14 +128,14 @@
 </template>
 
 <script>
-// import _ from 'lodash'
+import _ from 'lodash'
 import { sync } from 'vuex-pathify'
 import vueFilePond from 'vue-filepond'
 import 'filepond/dist/filepond.min.css'
 
 import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'
 
-import uploadFileMutation from 'gql/editor/upload.gql'
+import listAssetQuery from 'gql/editor/editor-media-query-list.gql'
 
 const FilePond = vueFilePond(FilePondPluginFileValidateType)
 
@@ -133,23 +169,81 @@ export default {
     },
     activeModal: sync('editor/activeModal')
   },
+  filters: {
+    prettyBytes(num) {
+      if (typeof num !== 'number' || isNaN(num)) {
+        throw new TypeError('Expected a number')
+      }
+
+      var exponent
+      var unit
+      var neg = num < 0
+      var units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+
+      if (neg) {
+        num = -num
+      }
+
+      if (num < 1) {
+        return (neg ? '-' : '') + num + ' B'
+      }
+
+      exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1)
+      num = (num / Math.pow(1000, exponent)).toFixed(2) * 1
+      unit = units[exponent]
+
+      return (neg ? '-' : '') + num + ' ' + unit
+    }
+  },
   methods: {
     insert () {
       this.activeModal = ''
     },
+    browse () {
+      this.$refs.pond.browse()
+    },
     async upload () {
       const files = this.$refs.pond.getFiles()
-      for (let fl of files) {
-        const resp = await this.$apollo.mutate({
-          mutation: uploadFileMutation,
-          variables: {
-            data: fl.file
-          },
-          context: {
-            hasUpload: true
-          }
+      if (files.length < 1) {
+        return this.$store.commit('showNotification', {
+          message: 'You must choose a file to upload first!',
+          style: 'warning',
+          icon: 'warning'
         })
-        console.info(resp)
+      }
+      for (let file of files) {
+        file.setMetadata({
+          path: '/universe'
+        })
+      }
+      await this.$refs.pond.processFiles()
+    },
+    async onFileProcessed (err, file) {
+      if (err) {
+        return this.$store.commit('showNotification', {
+          message: 'File upload failed.',
+          style: 'error',
+          icon: 'error'
+        })
+      }
+      _.delay(() => {
+        this.$refs.pond.removeFile(file.id)
+      }, 5000)
+
+      await this.$apollo.queries.assets.refetch()
+    }
+  },
+  apollo: {
+    assets: {
+      query: listAssetQuery,
+      variables: {
+        kind: 'IMAGE'
+      },
+      throttle: 1000,
+      fetchPolicy: 'network-only',
+      update: (data) => data.assets.list,
+      watchLoading (isLoading) {
+        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'editor-media-list-refresh')
       }
     }
   }
@@ -158,12 +252,25 @@ export default {
 
 <style lang='scss'>
 .editor-modal-media {
-    position: fixed;
-    top: 112px;
-    left: 64px;
-    z-index: 10;
-    width: calc(100vw - 64px - 17px);
-    height: calc(100vh - 112px - 24px);
-    background-color: rgba(darken(mc('grey', '900'), 3%), .9) !important;
+  position: fixed;
+  top: 112px;
+  left: 64px;
+  z-index: 10;
+  width: calc(100vw - 64px - 17px);
+  height: calc(100vh - 112px - 24px);
+  background-color: rgba(darken(mc('grey', '900'), 3%), .9) !important;
+  overflow: auto;
+
+  .filepond--root {
+    margin-bottom: 0;
+  }
+
+  .filepond--drop-label {
+    cursor: pointer;
+
+    > label {
+      cursor: pointer;
+    }
+  }
 }
 </style>

+ 14 - 0
client/graph/editor/editor-media-query-list.gql

@@ -0,0 +1,14 @@
+query ($root: String, $kind: AssetKind!) {
+  assets {
+    list(root:$root, kind: $kind) {
+      id
+      filename
+      ext
+      kind
+      mime
+      fileSize
+      createdAt
+      updatedAt
+    }
+  }
+}

+ 0 - 10
client/graph/editor/upload.gql

@@ -1,10 +0,0 @@
-mutation ($file: Upload!) {
-  assets {
-    upload(data:$file) {
-      responseResult {
-        succeeded
-        message
-      }
-    }
-  }
-}

+ 21 - 0
config.sample.yml

@@ -70,6 +70,15 @@ ssl:
   # Set to false to disable (default: 80):
   redirectNonSSLPort: 80
 
+# ---------------------------------------------------------------------
+# Database Pool Options
+# ---------------------------------------------------------------------
+# Refer to https://github.com/vincit/tarn.js for all possible options
+
+pool:
+  # min: 2
+  # max: 10
+
 # ---------------------------------------------------------------------
 # IP address the server should listen to
 # ---------------------------------------------------------------------
@@ -83,3 +92,15 @@ bindIP: 0.0.0.0
 # Possible values: error, warn, info (default), verbose, debug, silly
 
 logLevel: info
+
+# ---------------------------------------------------------------------
+# Upload Limits
+# ---------------------------------------------------------------------
+# If you're using a reverse-proxy in front of Wiki.ks, you must also
+# change your proxy upload limits!
+
+uploads:
+  # Maximum upload size in bytes per file (default: 5242880 (5 MB))
+  maxFileSize: 5242880
+  # Maximum file uploads per request (default: 20)
+  maxFiles: 10

+ 1 - 1
package.json

@@ -79,7 +79,6 @@
     "graphql-rate-limit-directive": "1.0.1",
     "graphql-subscriptions": "1.1.0",
     "graphql-tools": "4.0.4",
-    "graphql-upload": "8.0.6",
     "highlight.js": "9.15.6",
     "i18next": "15.1.0",
     "i18next-express-middleware": "1.8.0",
@@ -152,6 +151,7 @@
     "request": "2.88.0",
     "request-promise": "4.2.4",
     "safe-regex": "2.0.2",
+    "sanitize-filename": "1.6.1",
     "scim-query-filter-parser": "1.1.0",
     "semver": "6.0.0",
     "serve-favicon": "2.5.0",

+ 4 - 0
server/app/data.yml

@@ -17,8 +17,12 @@ defaults:
       storage: ./db.sqlite
     ssl:
       enabled: false
+    pool: {}
     bindIP: 0.0.0.0
     logLevel: info
+    uploads:
+      maxFileSize: 5242880
+      maxFiles: 10
     # DB defaults
     graphEndpoint: 'https://graph.requarks.io'
     lang:

+ 79 - 0
server/controllers/upload.js

@@ -0,0 +1,79 @@
+const express = require('express')
+const router = express.Router()
+const _ = require('lodash')
+const multer = require('multer')
+const path = require('path')
+const sanitize = require('sanitize-filename')
+
+/* global WIKI */
+
+/**
+ * Upload files
+ */
+router.post('/u', multer({
+  dest: path.join(WIKI.ROOTPATH, 'data/uploads'),
+  limits: {
+    fileSize: WIKI.config.uploads.maxFileSize,
+    files: WIKI.config.uploads.maxFiles
+  }
+}).array('mediaUpload'), async (req, res, next) => {
+  if (!_.some(req.user.permissions, pm => _.includes(['write:assets', 'manage:system'], pm))) {
+    return res.status(403).json({
+      succeeded: false,
+      message: 'You are not authorized to upload files.'
+    })
+  } else if (req.files.length < 1) {
+    return res.status(400).json({
+      succeeded: false,
+      message: 'Missing upload payload.'
+    })
+  } else if (req.files.length > 1) {
+    return res.status(400).json({
+      succeeded: false,
+      message: 'You cannot upload multiple files within the same request.'
+    })
+  }
+  const fileMeta = _.get(req, 'files[0]', false)
+  if (!fileMeta) {
+    return res.status(500).json({
+      succeeded: false,
+      message: 'Missing upload file metadata.'
+    })
+  }
+
+  let folderPath = ''
+  try {
+    const folderRaw = _.get(req, 'body.mediaUpload', false)
+    if (folderRaw) {
+      folderPath = _.get(JSON.parse(folderRaw), 'path', false)
+    }
+  } catch (err) {
+    return res.status(400).json({
+      succeeded: false,
+      message: 'Missing upload folder metadata.'
+    })
+  }
+
+  if (!WIKI.auth.checkAccess(req.user, ['write:assets'], { path: `${folderPath}/${fileMeta.originalname}`})) {
+    return res.status(403).json({
+      succeeded: false,
+      message: 'You are not authorized to upload files to this folder.'
+    })
+  }
+
+  await WIKI.models.assets.upload({
+    ...fileMeta,
+    originalname: sanitize(fileMeta.originalname).toLowerCase(),
+    folder: folderPath,
+    userId: req.user.id
+  })
+  res.send('ok')
+})
+
+router.get('/u', async (req, res, next) => {
+  res.json({
+    ok: true
+  })
+})
+
+module.exports = router

+ 1 - 0
server/core/db.js

@@ -67,6 +67,7 @@ module.exports = {
       asyncStackTraces: WIKI.IS_DEBUG,
       connection: dbConfig,
       pool: {
+        ...WIKI.config.pool,
         async afterCreate(conn, done) {
           // -> Set Connection App Name
           switch (WIKI.config.db.type) {

+ 0 - 58
server/db/migrations-sqlite/2.0.0-beta.1.js

@@ -1,15 +1,10 @@
 exports.up = knex => {
-  const dbCompat = {
-    charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`),
-    noForeign: WIKI.config.db.type === 'sqlite'
-  }
   return knex.schema
     // =====================================
     // MODEL TABLES
     // =====================================
     // ASSETS ------------------------------
     .createTable('assets', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('filename').notNullable()
       table.string('basename').notNullable()
@@ -26,7 +21,6 @@ exports.up = knex => {
     })
     // ASSET FOLDERS -----------------------
     .createTable('assetFolders', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('name').notNullable()
       table.string('slug').notNullable()
@@ -34,7 +28,6 @@ exports.up = knex => {
     })
     // AUTHENTICATION ----------------------
     .createTable('authentication', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.json('config').notNullable()
@@ -44,7 +37,6 @@ exports.up = knex => {
     })
     // COMMENTS ----------------------------
     .createTable('comments', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.text('content').notNullable()
       table.string('createdAt').notNullable()
@@ -55,14 +47,12 @@ exports.up = knex => {
     })
     // EDITORS -----------------------------
     .createTable('editors', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.json('config').notNullable()
     })
     // GROUPS ------------------------------
     .createTable('groups', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('name').notNullable()
       table.json('permissions').notNullable()
@@ -73,7 +63,6 @@ exports.up = knex => {
     })
     // LOCALES -----------------------------
     .createTable('locales', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('code', 2).notNullable().primary()
       table.json('strings')
       table.boolean('isRTL').notNullable().defaultTo(false)
@@ -84,7 +73,6 @@ exports.up = knex => {
     })
     // LOGGING ----------------------------
     .createTable('loggers', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.string('level').notNullable().defaultTo('warn')
@@ -92,13 +80,11 @@ exports.up = knex => {
     })
     // NAVIGATION ----------------------------
     .createTable('navigation', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.json('config')
     })
     // PAGE HISTORY ------------------------
     .createTable('pageHistory', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('path').notNullable()
       table.string('hash').notNullable()
@@ -119,7 +105,6 @@ exports.up = knex => {
     })
     // PAGES -------------------------------
     .createTable('pages', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('path').notNullable()
       table.string('hash').notNullable()
@@ -144,7 +129,6 @@ exports.up = knex => {
     })
     // PAGE TREE ---------------------------
     .createTable('pageTree', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('path').notNullable()
       table.integer('depth').unsigned().notNullable()
@@ -159,28 +143,24 @@ exports.up = knex => {
     })
     // RENDERERS ---------------------------
     .createTable('renderers', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.json('config')
     })
     // SEARCH ------------------------------
     .createTable('searchEngines', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.json('config')
     })
     // SETTINGS ----------------------------
     .createTable('settings', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.json('value')
       table.string('updatedAt').notNullable()
     })
     // STORAGE -----------------------------
     .createTable('storage', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.string('key').notNullable().primary()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.string('mode', ['sync', 'push', 'pull']).notNullable().defaultTo('push')
@@ -188,7 +168,6 @@ exports.up = knex => {
     })
     // TAGS --------------------------------
     .createTable('tags', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('tag').notNullable().unique()
       table.string('title')
@@ -197,7 +176,6 @@ exports.up = knex => {
     })
     // USER KEYS ---------------------------
     .createTable('userKeys', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('kind').notNullable()
       table.string('token').notNullable()
@@ -208,7 +186,6 @@ exports.up = knex => {
     })
     // USERS -------------------------------
     .createTable('users', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.string('email').notNullable()
       table.string('name').notNullable()
@@ -235,21 +212,18 @@ exports.up = knex => {
     // =====================================
     // PAGE HISTORY TAGS ---------------------------
     .createTable('pageHistoryTags', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.integer('pageId').unsigned().references('id').inTable('pageHistory').onDelete('CASCADE')
       table.integer('tagId').unsigned().references('id').inTable('tags').onDelete('CASCADE')
     })
     // PAGE TAGS ---------------------------
     .createTable('pageTags', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.integer('pageId').unsigned().references('id').inTable('pages').onDelete('CASCADE')
       table.integer('tagId').unsigned().references('id').inTable('tags').onDelete('CASCADE')
     })
     // USER GROUPS -------------------------
     .createTable('userGroups', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.increments('id').primary()
       table.integer('userId').unsigned().references('id').inTable('users').onDelete('CASCADE')
       table.integer('groupId').unsigned().references('id').inTable('groups').onDelete('CASCADE')
@@ -257,39 +231,7 @@ exports.up = knex => {
     // =====================================
     // REFERENCES
     // =====================================
-    // .table('assets', table => {
-    //   dbCompat.noForeign ? table.integer('folderId').unsigned() : table.integer('folderId').unsigned().references('id').inTable('assetFolders')
-    //   dbCompat.noForeign ? table.integer('authorId').unsigned() : table.integer('authorId').unsigned().references('id').inTable('users')
-    // })
-    // .table('comments', table => {
-    //   dbCompat.noForeign ? table.integer('pageId').unsigned() : table.integer('pageId').unsigned().references('id').inTable('pages')
-    //   dbCompat.noForeign ? table.integer('authorId').unsigned() : table.integer('authorId').unsigned().references('id').inTable('users')
-    // })
-    // .table('pageHistory', table => {
-    //   dbCompat.noForeign ? table.integer('pageId').unsigned() : table.integer('pageId').unsigned().references('id').inTable('pages')
-    //   dbCompat.noForeign ? table.string('editorKey') : table.string('editorKey').references('key').inTable('editors')
-    //   dbCompat.noForeign ? table.string('localeCode', 2) : table.string('localeCode', 2).references('code').inTable('locales')
-    //   dbCompat.noForeign ? table.integer('authorId').unsigned() : table.integer('authorId').unsigned().references('id').inTable('users')
-    // })
-    // .table('pages', table => {
-    //   dbCompat.noForeign ? table.string('editorKey') : table.string('editorKey').references('key').inTable('editors')
-    //   dbCompat.noForeign ? table.string('localeCode', 2) : table.string('localeCode', 2).references('code').inTable('locales')
-    //   dbCompat.noForeign ? table.integer('authorId').unsigned() : table.integer('authorId').unsigned().references('id').inTable('users')
-    //   dbCompat.noForeign ? table.integer('creatorId').unsigned() : table.integer('creatorId').unsigned().references('id').inTable('users')
-    // })
-    // .table('pageTree', table => {
-    //   dbCompat.noForeign ? table.integer('parent').unsigned() : table.integer('parent').unsigned().references('id').inTable('pageTree')
-    //   dbCompat.noForeign ? table.integer('pageId').unsigned() : table.integer('pageId').unsigned().references('id').inTable('pages')
-    //   dbCompat.noForeign ? table.string('localeCode', 2) : table.string('localeCode', 2).references('code').inTable('locales')
-    // })
-    // .table('userKeys', table => {
-    //   dbCompat.noForeign ? table.integer('userId').unsigned() : table.integer('userId').unsigned().references('id').inTable('users')
-    // })
     .table('users', table => {
-      // dbCompat.noForeign ? table.string('providerKey') : table.string('providerKey').references('key').inTable('authentication').notNullable().defaultTo('local')
-      // dbCompat.noForeign ? table.string('localeCode', 2) : table.string('localeCode', 2).references('code').inTable('locales').notNullable().defaultTo('en')
-      // dbCompat.noForeign ? table.string('defaultEditor') : table.string('defaultEditor').references('key').inTable('editors').notNullable().defaultTo('markdown')
-
       table.unique(['providerKey', 'email'])
     })
 }

+ 15 - 0
server/db/migrations-sqlite/2.0.0-beta.127.js

@@ -0,0 +1,15 @@
+exports.up = knex => {
+  return knex.schema
+    .table('assets', table => {
+      table.dropColumn('basename')
+      table.string('hash').notNullable()
+    })
+}
+
+exports.down = knex => {
+  return knex.schema
+    .table('assets', table => {
+      table.dropColumn('hash')
+      table.string('basename').notNullable()
+    })
+}

+ 0 - 4
server/db/migrations-sqlite/2.0.0-beta.99.js

@@ -1,10 +1,6 @@
 exports.up = knex => {
-  const dbCompat = {
-    charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`)
-  }
   return knex.schema
     .createTable('assetData', table => {
-      if (dbCompat.charset) { table.charset('utf8mb4') }
       table.integer('id').primary()
       table.binary('data').notNullable()
     })

+ 15 - 0
server/db/migrations/2.0.0-beta.127.js

@@ -0,0 +1,15 @@
+exports.up = knex => {
+  return knex.schema
+    .table('assets', table => {
+      table.dropColumn('basename')
+      table.string('hash').notNullable()
+    })
+}
+
+exports.down = knex => {
+  return knex.schema
+    .table('assets', table => {
+      table.dropColumn('hash')
+      table.string('basename').notNullable()
+    })
+}

+ 2 - 2
server/graph/index.js

@@ -7,7 +7,7 @@ const PubSub = require('graphql-subscriptions').PubSub
 const { LEVEL, MESSAGE } = require('triple-beam')
 const Transport = require('winston-transport')
 const { createRateLimitTypeDef } = require('graphql-rate-limit-directive')
-const { GraphQLUpload } = require('graphql-upload')
+// const { GraphQLUpload } = require('graphql-upload')
 
 /* global WIKI */
 
@@ -28,7 +28,7 @@ schemas.forEach(schema => {
 // Resolvers
 
 let resolvers = {
-  Upload: GraphQLUpload
+  // Upload: GraphQLUpload
 }
 const resolversObj = _.values(autoload(path.join(WIKI.SERVERPATH, 'graph/resolvers')))
 resolversObj.forEach(resolver => {

+ 60 - 0
server/graph/resolvers/asset.js

@@ -0,0 +1,60 @@
+
+/* global WIKI */
+
+const gql = require('graphql')
+
+module.exports = {
+  Query: {
+    async assets() { return {} }
+  },
+  Mutation: {
+    async assets() { return {} }
+  },
+  AssetQuery: {
+    async list(obj, args, context) {
+      const result = await WIKI.models.assets.query().where({
+        folderId: null,
+        kind: args.kind.toLowerCase()
+      })
+      return result.map(a => ({
+        ...a,
+        kind: a.kind.toUpperCase()
+      }))
+    }
+  },
+  AssetMutation: {
+    // deleteFile(obj, args) {
+    //   return WIKI.models.File.destroy({
+    //     where: {
+    //       id: args.id
+    //     },
+    //     limit: 1
+    //   })
+    // },
+    // renameFile(obj, args) {
+    //   return WIKI.models.File.update({
+    //     filename: args.filename
+    //   }, {
+    //     where: { id: args.id }
+    //   })
+    // },
+    // moveFile(obj, args) {
+    //   return WIKI.models.File.findById(args.fileId).then(fl => {
+    //     if (!fl) {
+    //       throw new gql.GraphQLError('Invalid File ID')
+    //     }
+    //     return WIKI.models.Folder.findById(args.folderId).then(fld => {
+    //       if (!fld) {
+    //         throw new gql.GraphQLError('Invalid Folder ID')
+    //       }
+    //       return fl.setFolder(fld)
+    //     })
+    //   })
+    // }
+  }
+  // File: {
+  //   folder(fl) {
+  //     return fl.getFolder()
+  //   }
+  // }
+}

+ 0 - 51
server/graph/resolvers/file.js

@@ -1,51 +0,0 @@
-
-/* global WIKI */
-
-const gql = require('graphql')
-
-module.exports = {
-  // Query: {
-  //   files(obj, args, context, info) {
-  //     return WIKI.models.File.findAll({ where: args })
-  //   }
-  // },
-  // Mutation: {
-  //   uploadFile(obj, args) {
-  //     // todo
-  //     return WIKI.models.File.create(args)
-  //   },
-  //   deleteFile(obj, args) {
-  //     return WIKI.models.File.destroy({
-  //       where: {
-  //         id: args.id
-  //       },
-  //       limit: 1
-  //     })
-  //   },
-  //   renameFile(obj, args) {
-  //     return WIKI.models.File.update({
-  //       filename: args.filename
-  //     }, {
-  //       where: { id: args.id }
-  //     })
-  //   },
-  //   moveFile(obj, args) {
-  //     return WIKI.models.File.findById(args.fileId).then(fl => {
-  //       if (!fl) {
-  //         throw new gql.GraphQLError('Invalid File ID')
-  //       }
-  //       return WIKI.models.Folder.findById(args.folderId).then(fld => {
-  //         if (!fld) {
-  //           throw new gql.GraphQLError('Invalid Folder ID')
-  //         }
-  //         return fl.setFolder(fld)
-  //       })
-  //     })
-  //   }
-  // },
-  // File: {
-  //   folder(fl) {
-  //     return fl.getFolder()
-  //   }
-  // }
-}

+ 16 - 2
server/graph/schemas/asset.graphql

@@ -17,8 +17,8 @@ extend type Mutation {
 type AssetQuery {
   list(
     root: String
-    kind: [AssetKind]
-  ): [AssetItem]
+    kind: AssetKind
+  ): [AssetItem] @auth(requires: ["manage:system", "read:assets"])
 }
 
 # -----------------------------------------------
@@ -37,6 +37,20 @@ type AssetMutation {
 
 type AssetItem {
   id: Int!
+  filename: String!
+  ext: String!
+  kind: AssetKind!
+  mime: String!
+  fileSize: Int!
+  metadata: String
+  createdAt: Date!
+  updatedAt: Date!
+  folder: AssetFolder
+  author: User
+}
+
+type AssetFolder {
+  id: Int!
 }
 
 enum AssetKind {

+ 12 - 0
server/helpers/asset.js

@@ -0,0 +1,12 @@
+const crypto = require('crypto')
+
+/* global WIKI */
+
+module.exports = {
+  /**
+   * Generate unique hash from page
+   */
+  generateHash(assetPath) {
+    return crypto.createHash('sha1').update(assetPath).digest('hex')
+  }
+}

+ 12 - 4
server/helpers/page.js

@@ -3,7 +3,6 @@ const _ = require('lodash')
 const crypto = require('crypto')
 
 const localeSegmentRegex = /^[A-Z]{2}(-[A-Z]{2})?$/i
-const systemSegmentRegex = /^[A-Z]\//i
 
 /* global WIKI */
 
@@ -67,8 +66,17 @@ module.exports = {
    */
   isReservedPath(rawPath) {
     const firstSection = _.head(rawPath.split('/'))
-    return _.some(WIKI.data.reservedPaths, p => {
-      return p === firstSection || systemSegmentRegex.test(rawPath)
-    })
+    if (firstSection.length === 1) {
+      return true
+    } else if (localeSegmentRegex.test(firstSection)) {
+      return true
+    } else if (
+      _.some(WIKI.data.reservedPaths, p => {
+        return p === firstSection
+      })) {
+      return true
+    } else {
+      return false
+    }
   }
 }

+ 1 - 1
server/master.js

@@ -140,7 +140,6 @@ module.exports = async () => {
       path: '/graphql-subscriptions'
     }
   })
-  app.use('/graphql', mw.upload)
   apolloServer.applyMiddleware({ app })
 
   // ----------------------------------------
@@ -148,6 +147,7 @@ module.exports = async () => {
   // ----------------------------------------
 
   app.use('/', ctrl.auth)
+  app.use('/', ctrl.upload)
   app.use('/', ctrl.common)
 
   // ----------------------------------------

+ 35 - 0
server/models/assetFolders.js

@@ -0,0 +1,35 @@
+/* global WIKI */
+
+const Model = require('objection').Model
+
+/**
+ * Users model
+ */
+module.exports = class AssetFolder extends Model {
+  static get tableName() { return 'assetFolders' }
+
+  static get jsonSchema () {
+    return {
+      type: 'object',
+
+      properties: {
+        id: {type: 'integer'},
+        name: {type: 'string'},
+        slug: {type: 'string'}
+      }
+    }
+  }
+
+  static get relationMappings() {
+    return {
+      parent: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: AssetFolder,
+        join: {
+          from: 'assetFolders.folderId',
+          to: 'assetFolders.id'
+        }
+      }
+    }
+  }
+}

+ 97 - 0
server/models/assets.js

@@ -0,0 +1,97 @@
+/* global WIKI */
+
+const Model = require('objection').Model
+const moment = require('moment')
+const path = require('path')
+const fs = require('fs-extra')
+const _ = require('lodash')
+const assetHelper = require('../helpers/asset')
+
+/**
+ * Users model
+ */
+module.exports = class Asset extends Model {
+  static get tableName() { return 'assets' }
+
+  static get jsonSchema () {
+    return {
+      type: 'object',
+
+      properties: {
+        id: {type: 'integer'},
+        filename: {type: 'string'},
+        hash: {type: 'string'},
+        ext: {type: 'string'},
+        kind: {type: 'string'},
+        mime: {type: 'string'},
+        fileSize: {type: 'integer'},
+        metadata: {type: 'object'},
+        createdAt: {type: 'string'},
+        updatedAt: {type: 'string'}
+      }
+    }
+  }
+
+  static get relationMappings() {
+    return {
+      author: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: require('./users'),
+        join: {
+          from: 'assets.authorId',
+          to: 'users.id'
+        }
+      },
+      folder: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: require('./assetFolders'),
+        join: {
+          from: 'assets.folderId',
+          to: 'assetFolders.id'
+        }
+      }
+    }
+  }
+
+  async $beforeUpdate(opt, context) {
+    await super.$beforeUpdate(opt, context)
+
+    this.updatedAt = moment.utc().toISOString()
+  }
+  async $beforeInsert(context) {
+    await super.$beforeInsert(context)
+
+    this.createdAt = moment.utc().toISOString()
+    this.updatedAt = moment.utc().toISOString()
+  }
+
+  static async upload(opts) {
+    const fileInfo = path.parse(opts.originalname)
+    const fileHash = assetHelper.generateHash(`${opts.folder}/${opts.originalname}`)
+
+    // Create asset entry
+    const asset = await WIKI.models.assets.query().insert({
+      filename: opts.originalname,
+      hash: fileHash,
+      ext: fileInfo.ext,
+      kind: _.startsWith(opts.mimetype, 'image/') ? 'image' : 'binary',
+      mime: opts.mimetype,
+      fileSize: opts.size,
+      authorId: opts.userId
+    })
+
+    // Save asset data
+    try {
+      const fileBuffer = await fs.readFile(opts.path)
+      await WIKI.models.knex('assetData').insert({
+        id: asset.id,
+        data: fileBuffer
+      })
+    } catch (err) {
+      WIKI.logger.warn(err)
+    }
+
+    // Move temp upload to cache
+    await fs.move(opts.path, path.join(process.cwd(), `data/cache/${fileHash}.dat`))
+  }
+}