feat: image upload + list root assets (wip)

This commit is contained in:
Nick 2019-05-13 01:15:27 -04:00
parent 89c07a716a
commit 6b886b6e3f
23 changed files with 526 additions and 171 deletions

2
.nvmrc
View File

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

View File

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

View File

@ -11,46 +11,82 @@
v-btn.ml-3.my-0.radius-7(outline, large, color='teal', disabled) v-btn.ml-3.my-0.radius-7(outline, large, color='teal', disabled)
v-icon(left) keyboard_arrow_up v-icon(left) keyboard_arrow_up
span Parent Folder 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 v-icon(left) add
span New Folder span New Folder
v-list.mt-3(dense, two-line) 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(@click='')
v-list-tile-avatar v-list-tile-avatar
v-avatar.radius-7(color='teal') v-avatar.radius-7(color='teal')
v-icon(dark) image v-icon(dark) image
v-list-tile-content v-list-tile-content
v-list-tile-title Image {{item}} v-list-tile-title {{asset.filename}}
v-list-tile-sub-title 1024x768, 10 KBs v-list-tile-sub-title 1024x768
v-list-tile-action 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-list-tile-action
v-chip.teal--text(label, small, color='teal lighten-5') JPG v-menu(offset-x)
v-divider(v-if='idx < 10 - 1') 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 .d-flex.mt-3
v-toolbar.radius-7(flat, color='grey lighten-4', dense, height='44') v-toolbar.radius-7(flat, color='grey lighten-4', dense, height='44')
.body-2 / universe .body-2 / #[em root]
v-spacer v-spacer
.body-1.grey--text.text--darken-1 10 files .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 v-icon(left) save_alt
span Insert span Insert
v-flex(xs3) v-flex(xs3)
v-card.radius-7.animated.fadeInRight.wait-p3s(light) v-card.radius-7.animated.fadeInRight.wait-p3s(light)
v-card-text v-card-text
v-toolbar.radius-7(color='teal lighten-5', dense, flat) .d-flex
v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
v-icon.mr-3(color='teal') cloud_upload v-icon.mr-3(color='teal') cloud_upload
.body-2.teal--text Upload Images .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( file-pond.mt-3(
name='mediaUpload' name='mediaUpload'
ref='pond' ref='pond'
label-idle='Browse or Drop files here...' label-idle='Browse or Drop files here...'
allow-multiple='true' 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' :files='files'
max-files='10' max-files='10'
server='/u'
:instant-upload='false'
:allow-revert='false'
@processfile='onFileProcessed'
) )
v-divider v-divider
v-card-actions.pa-3 v-card-actions.pa-3
@ -92,14 +128,14 @@
</template> </template>
<script> <script>
// import _ from 'lodash' import _ from 'lodash'
import { sync } from 'vuex-pathify' import { sync } from 'vuex-pathify'
import vueFilePond from 'vue-filepond' import vueFilePond from 'vue-filepond'
import 'filepond/dist/filepond.min.css' import 'filepond/dist/filepond.min.css'
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type' 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) const FilePond = vueFilePond(FilePondPluginFileValidateType)
@ -133,23 +169,81 @@ export default {
}, },
activeModal: sync('editor/activeModal') 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: { methods: {
insert () { insert () {
this.activeModal = '' this.activeModal = ''
}, },
browse () {
this.$refs.pond.browse()
},
async upload () { async upload () {
const files = this.$refs.pond.getFiles() const files = this.$refs.pond.getFiles()
for (let fl of files) { if (files.length < 1) {
const resp = await this.$apollo.mutate({ return this.$store.commit('showNotification', {
mutation: uploadFileMutation, message: 'You must choose a file to upload first!',
variables: { style: 'warning',
data: fl.file icon: 'warning'
},
context: {
hasUpload: true
}
}) })
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')
} }
} }
} }
@ -165,5 +259,18 @@ export default {
width: calc(100vw - 64px - 17px); width: calc(100vw - 64px - 17px);
height: calc(100vh - 112px - 24px); height: calc(100vh - 112px - 24px);
background-color: rgba(darken(mc('grey', '900'), 3%), .9) !important; 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> </style>

View File

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

View File

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

View File

@ -70,6 +70,15 @@ ssl:
# Set to false to disable (default: 80): # Set to false to disable (default: 80):
redirectNonSSLPort: 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 # 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 # Possible values: error, warn, info (default), verbose, debug, silly
logLevel: info 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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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()
})
}

View File

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

View File

@ -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()
})
}

View File

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

View File

@ -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()
// }
// }
}

View File

@ -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()
// }
// }
}

View File

@ -17,8 +17,8 @@ extend type Mutation {
type AssetQuery { type AssetQuery {
list( list(
root: String root: String
kind: [AssetKind] kind: AssetKind
): [AssetItem] ): [AssetItem] @auth(requires: ["manage:system", "read:assets"])
} }
# ----------------------------------------------- # -----------------------------------------------
@ -37,6 +37,20 @@ type AssetMutation {
type AssetItem { type AssetItem {
id: Int! 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 { enum AssetKind {

12
server/helpers/asset.js Normal file
View File

@ -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')
}
}

View File

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

View File

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

View File

@ -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
server/models/assets.js Normal file
View File

@ -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`))
}
}