feat: page Rules access check

This commit is contained in:
Nicolas Giard 2019-01-12 18:33:30 -05:00
parent 75eb277401
commit 7e62c01ed1
34 changed files with 581 additions and 725 deletions

View File

@ -7,40 +7,42 @@
v-list-tile.pt-2(to='/dashboard')
v-list-tile-avatar: v-icon dashboard
v-list-tile-title {{ $t('admin:dashboard.title') }}
v-divider.my-2
v-subheader.pl-4 {{ $t('admin:nav.site') }}
v-list-tile(to='/general', v-if='hasPermission(`manage:system`)')
v-list-tile-avatar: v-icon widgets
v-list-tile-title {{ $t('admin:general.title') }}
v-list-tile(to='/locale', v-if='hasPermission(`manage:system`)')
v-list-tile-avatar: v-icon language
v-list-tile-title {{ $t('admin:locale.title') }}
v-list-tile(to='/navigation', v-if='hasPermission([`manage:system`, `manage:navigation`])')
v-list-tile-avatar: v-icon near_me
v-list-tile-title {{ $t('admin:navigation.title') }}
v-list-tile(to='/pages')
v-list-tile-avatar: v-icon insert_drive_file
v-list-tile-title {{ $t('admin:pages.title') }}
v-list-tile-action
v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
.caption.grey--text {{ info.pagesTotal }}
v-list-tile(to='/theme', v-if='hasPermission([`manage:system`, `manage:theme`])')
v-list-tile-avatar: v-icon palette
v-list-tile-title {{ $t('admin:theme.title') }}
v-divider.my-2
v-subheader.pl-4 {{ $t('admin:nav.users') }}
v-list-tile(to='/groups')
v-list-tile-avatar: v-icon people
v-list-tile-title {{ $t('admin:groups.title') }}
v-list-tile-action
v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
.caption.grey--text {{ info.groupsTotal }}
v-list-tile(to='/users')
v-list-tile-avatar: v-icon perm_identity
v-list-tile-title {{ $t('admin:users.title') }}
v-list-tile-action
v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
.caption.grey--text {{ info.usersTotal }}
template(v-if='hasPermission([`manage:system`, `manage:navigation`, `write:pages`, `manage:pages`, `delete:pages`])')
v-divider.my-2
v-subheader.pl-4 {{ $t('admin:nav.site') }}
v-list-tile(to='/general', v-if='hasPermission(`manage:system`)')
v-list-tile-avatar: v-icon widgets
v-list-tile-title {{ $t('admin:general.title') }}
v-list-tile(to='/locale', v-if='hasPermission(`manage:system`)')
v-list-tile-avatar: v-icon language
v-list-tile-title {{ $t('admin:locale.title') }}
v-list-tile(to='/navigation', v-if='hasPermission([`manage:system`, `manage:navigation`])')
v-list-tile-avatar: v-icon near_me
v-list-tile-title {{ $t('admin:navigation.title') }}
v-list-tile(to='/pages', v-if='hasPermission([`manage:system`, `write:pages`, `manage:pages`, `delete:pages`])')
v-list-tile-avatar: v-icon insert_drive_file
v-list-tile-title {{ $t('admin:pages.title') }}
v-list-tile-action
v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
.caption.grey--text {{ info.pagesTotal }}
v-list-tile(to='/theme', v-if='hasPermission([`manage:system`, `manage:theme`])')
v-list-tile-avatar: v-icon palette
v-list-tile-title {{ $t('admin:theme.title') }}
template(v-if='hasPermission([`manage:system`, `manage:groups`, `write:groups`, `manage:users`, `write:users`])')
v-divider.my-2
v-subheader.pl-4 {{ $t('admin:nav.users') }}
v-list-tile(to='/groups', v-if='hasPermission([`manage:system`, `manage:groups`, `write:groups`])')
v-list-tile-avatar: v-icon people
v-list-tile-title {{ $t('admin:groups.title') }}
v-list-tile-action
v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
.caption.grey--text {{ info.groupsTotal }}
v-list-tile(to='/users', v-if='hasPermission([`manage:system`, `manage:groups`, `write:groups`, `manage:users`, `write:users`])')
v-list-tile-avatar: v-icon perm_identity
v-list-tile-title {{ $t('admin:users.title') }}
v-list-tile-action
v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
.caption.grey--text {{ info.usersTotal }}
template(v-if='hasPermission(`manage:system`)')
v-divider.my-2
v-subheader.pl-4 {{ $t('admin:nav.modules') }}
@ -62,8 +64,8 @@
v-list-tile(to='/storage')
v-list-tile-avatar: v-icon storage
v-list-tile-title {{ $t('admin:storage.title') }}
v-divider.my-2
template(v-if='hasPermission([`manage:system`, `manage:api`])')
v-divider.my-2
v-subheader.pl-4 {{ $t('admin:nav.system') }}
v-list-tile(to='/api', v-if='hasPermission([`manage:system`, `manage:api`])')
v-list-tile-avatar: v-icon call_split
@ -74,8 +76,8 @@
v-list-tile(to='/system', v-if='hasPermission(`manage:system`)')
v-list-tile-avatar: v-icon tune
v-list-tile-title {{ $t('admin:system.title') }}
v-list-tile(to='/utilities', v-if='hasPermission(`manage:system`)')
v-list-tile-avatar: v-icon build
v-list-tile(to='/utilities', v-if='hasPermission(`manage:system`)', disabled)
v-list-tile-avatar: v-icon(color='grey lighten-2') build
v-list-tile-title {{ $t('admin:utilities.title') }}
v-list-tile(to='/dev', v-if='hasPermission([`manage:system`, `manage:api`])')
v-list-tile-avatar: v-icon weekend

View File

@ -45,7 +45,7 @@
:class='isLatestVersion ? "teal lighten-2" : "red lighten-2"'
dark
)
v-btn(fab, absolute, right, top, small, light, to='system')
v-btn(fab, absolute, right, top, small, light, to='system', v-if='hasPermission(`manage:system`)')
v-icon(v-if='isLatestVersion', color='teal') build
v-icon(v-else, color='red darken-4') get_app
v-card-text
@ -101,6 +101,7 @@
</template>
<script>
import _ from 'lodash'
import AnimatedNumber from 'animated-number-vue'
import { get } from 'vuex-pathify'
@ -118,10 +119,20 @@ export default {
isLatestVersion() {
return this.info.currentVersion === this.info.latestVersion
},
info: get('admin/info')
info: get('admin/info'),
permissions: get('user/permissions')
},
methods: {
round(val) { return Math.round(val) }
round(val) { return Math.round(val) },
hasPermission(prm) {
if (_.isArray(prm)) {
return _.some(prm, p => {
return _.includes(this.permissions, p)
})
} else {
return _.includes(this.permissions, prm)
}
}
}
}
</script>

View File

@ -78,8 +78,8 @@
dense
)
template(slot='selection', slot-scope='{ item, index }')
v-chip.white--text.ml-0(v-if='index <= 2', small, label, :color='rule.deny ? `red` : `green`').caption {{ item.value }}
v-chip.white--text.ml-0(v-if='index === 3', small, label, :color='rule.deny ? `red lighten-2` : `green lighten-2`').caption + {{ rule.roles.length - 3 }} more
v-chip.white--text.ml-0(v-if='index <= 1', small, label, :color='rule.deny ? `red` : `green`').caption {{ item.value }}
v-chip.white--text.ml-0(v-if='index === 2', small, label, :color='rule.deny ? `red lighten-2` : `green lighten-2`').caption + {{ rule.roles.length - 2 }} more
template(slot='item', slot-scope='props')
v-list-tile-action(style='min-width: 30px;')
v-checkbox(
@ -163,6 +163,26 @@
v-btn(icon, @click='removeRule(rule.id)')
v-icon(:color='$vuetify.dark ? `grey` : `blue-grey`') clear
v-divider.mt-3
v-subheader.pl-0 Rules Order
.body-1.pl-3 Rules are applied in order of path specificity. A more precise path will always override a less defined path.
.body-1.pl-4 For example, #[span.teal--text /geography/countries] will override #[span.teal--text /geography].
.body-1.pl-3.pt-2 When 2 rules have the same specificity, the priority is given from lowest to highest as follows:
.body-1.pl-3.pt-1
ul
li
strong Path Starts With...
em.caption.pl-1 (lowest)
li
strong Path Ends With...
li
strong Path Matches Regex...
li
strong Path Is Exactly...
em.caption.pl-1 (highest)
.body-1.pl-3.pt-2 When 2 rules have the same path specificity AND the same match type, #[strong.red--text DENY] will always override an #[strong.green--text ALLOW] rule.
</template>
<script>
@ -178,16 +198,16 @@ export default {
data() {
return {
roles: [
{ text: 'Read Pages', value: 'READ', icon: 'insert_drive_file' },
{ text: 'Create Pages', value: 'WRITE', icon: 'insert_drive_file' },
{ text: 'Edit + Move Pages', value: 'MANAGE', icon: 'insert_drive_file' },
{ text: 'Delete Pages', value: 'DELETE', icon: 'insert_drive_file' },
{ text: 'Read / Use Assets', value: 'AS_READ', icon: 'camera' },
{ text: 'Upload Assets', value: 'AS_WRITE', icon: 'camera' },
{ text: 'Edit + Delete Assets', value: 'AS_MANAGE', icon: 'camera' },
{ text: 'Read Comments', value: 'CM_READ', icon: 'insert_comment' },
{ text: 'Create Comments', value: 'CM_WRITE', icon: 'insert_comment' },
{ text: 'Edit + Delete Comments', value: 'CM_MANAGE', icon: 'insert_comment' }
{ text: 'Read Pages', value: 'read:pages', icon: 'insert_drive_file' },
{ text: 'Create Pages', value: 'write:pages', icon: 'insert_drive_file' },
{ text: 'Edit + Move Pages', value: 'manage:pages', icon: 'insert_drive_file' },
{ text: 'Delete Pages', value: 'delete:pages', icon: 'insert_drive_file' },
{ text: 'Read / Use Assets', value: 'read:assets', icon: 'camera' },
{ text: 'Upload Assets', value: 'write:assets', icon: 'camera' },
{ text: 'Edit + Delete Assets', value: 'manage:assets', icon: 'camera' },
{ text: 'Read Comments', value: 'read:comments', icon: 'insert_comment' },
{ text: 'Create Comments', value: 'write:comments', icon: 'insert_comment' },
{ text: 'Edit + Delete Comments', value: 'manage:comments', icon: 'insert_comment' }
],
matches: [
{ text: 'Path Starts With...', value: 'START', icon: '/...' },

View File

@ -97,42 +97,38 @@
v-btn.btn-animate-rotate(icon, href='/a', slot='activator')
v-icon(color='grey') settings
span Admin
v-menu(offset-y, min-width='300')
v-menu(v-if='isAuthenticated', offset-y, min-width='300')
v-tooltip(bottom, slot='activator')
v-btn.btn-animate-grow(icon, slot='activator', outline, :color='isAuthenticated ? `blue` : `grey darken-3`')
v-btn.btn-animate-grow(icon, slot='activator', outline, color='blue')
v-icon(color='grey') account_circle
span Account
v-list.py-0
template(v-if='isAuthenticated')
v-list-tile.py-3.grey(avatar, :class='$vuetify.dark ? `darken-4-l5` : `lighten-5`')
v-list-tile-avatar
v-avatar.blue(v-if='picture.kind === `initials`', :size='40')
span.white--text.subheading {{picture.initials}}
v-avatar(v-else-if='picture.kind === `image`', :size='40')
v-img(:src='picture.url')
v-list-tile-content
v-list-tile-title {{name}}
v-list-tile-sub-title {{email}}
v-divider.my-0
v-list-tile(href='/w')
v-list-tile-action: v-icon(color='blue') web
v-list-tile-title My Wiki
v-divider.my-0
v-list-tile(href='/p')
v-list-tile-action: v-icon(color='blue') person
v-list-tile-title Profile
v-divider.my-0
v-list-tile(@click='logout')
v-list-tile-action: v-icon(color='red') exit_to_app
v-list-tile-title Logout
template(v-else)
v-list-tile(href='/login')
v-list-tile-action: v-icon(color='grey') person
v-list-tile-title Login
v-divider.my-0
v-list-tile(href='/register')
v-list-tile-action: v-icon(color='grey') person_add
v-list-tile-title Register
v-list-tile.py-3.grey(avatar, :class='$vuetify.dark ? `darken-4-l5` : `lighten-5`')
v-list-tile-avatar
v-avatar.blue(v-if='picture.kind === `initials`', :size='40')
span.white--text.subheading {{picture.initials}}
v-avatar(v-else-if='picture.kind === `image`', :size='40')
v-img(:src='picture.url')
v-list-tile-content
v-list-tile-title {{name}}
v-list-tile-sub-title {{email}}
v-divider.my-0
v-list-tile(href='/w')
v-list-tile-action: v-icon(color='blue') web
v-list-tile-title My Wiki
v-divider.my-0
v-list-tile(href='/p')
v-list-tile-action: v-icon(color='blue') person
v-list-tile-title Profile
v-divider.my-0
v-list-tile(@click='logout')
v-list-tile-action: v-icon(color='red') exit_to_app
v-list-tile-title Logout
v-tooltip(v-else, left)
v-btn(icon, slot='activator', outline, color='grey darken-3', href='/login')
v-icon(color='grey') account_circle
span Login
page-selector(mode='create', v-model='newPageModal', :open-handler='pageNewCreate')
</template>

View File

@ -1,6 +0,0 @@
query($locale: String!, $namespace: String!) {
translations(locale:$locale, namespace:$namespace) {
key
value
}
}

View File

@ -0,0 +1,8 @@
query($locale: String!, $namespace: String!) {
localization {
translations(locale:$locale, namespace:$namespace) {
key
value
}
}
}

View File

@ -6,7 +6,7 @@ import _ from 'lodash'
/* global siteConfig, graphQL */
import localeQuery from 'gql/common/common-locale-query.gql'
import localeQuery from 'gql/common/common-localization-query-translations.gql'
export default {
VueI18Next,
@ -28,8 +28,8 @@ export default {
}
}).then(resp => {
let ns = {}
if (resp.data.translations.length > 0) {
resp.data.translations.forEach(entry => {
if (_.get(resp, 'data.localization.translations', []).length > 0) {
resp.data.localization.translations.forEach(entry => {
_.set(ns, entry.key, entry.value)
})
}

View File

@ -23,6 +23,7 @@
// @import 'node_modules/diff2html/dist/diff2html.min';
@import 'pages/new';
@import 'pages/notfound';
@import 'pages/unauthorized';
@import 'pages/welcome';
@import 'pages/error';

View File

@ -0,0 +1,81 @@
.notfound {
background: linear-gradient(to bottom, darken(mc('red', '900'), 25%) 0%, mc('red', '600') 100%);
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: mc('grey', '50');
&::before {
content: '';
display:block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-image: url('../static/svg/motif-circuit.svg');
background-position: center center;
background-repeat: repeat;
background-size: 200px;
z-index: 0;
opacity: .75;
animation: onboardingBgReveal 80s linear infinite;
@include keyframes(onboardingBgReveal) {
0% {
background-position-y: 0;
}
100% {
background-position-y: -2000px;
}
}
}
&::after {
content: '';
position: absolute;
background-color: transparent;
background-image: url('../static/svg/motif-overlay.svg');
background-attachment: fixed;
background-size: cover;
opacity: .5;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
&-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 2;
}
img {
height: 250px;
margin-bottom: 3rem;
z-index: 2;
animation-duration: 2s;
@include until($tablet) {
height: 200px;
}
}
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
z-index: 2;
}
h2 {
margin-bottom: 3rem;
z-index: 2;
}
.v-btn {
z-index: 2;
}
}

View File

@ -147,6 +147,7 @@
"remove-markdown": "0.3.0",
"request": "2.88.0",
"request-promise": "4.2.2",
"safe-regex": "2.0.1",
"scim-query-filter-parser": "1.1.0",
"semver": "5.6.0",
"serve-favicon": "2.5.0",

View File

@ -131,6 +131,7 @@ router.get('/*', async (req, res, next) => {
if (pageArgs.path === 'home') {
return res.redirect('/login')
} else {
_.set(res.locals, 'pageMeta.title', 'Unauthorized')
return res.render('unauthorized', { action: 'view'})
}
}
@ -151,7 +152,11 @@ router.get('/*', async (req, res, next) => {
res.render('welcome')
} else {
_.set(res.locals, 'pageMeta.title', 'Page Not Found')
res.status(404).render('new', { pagePath: req.path })
if (WIKI.auth.checkAccess(req.user, ['write:pages'], pageArgs)) {
res.status(404).render('new', { pagePath: req.path })
} else {
res.render('notfound', { action: 'view'})
}
}
})

View File

@ -15,6 +15,7 @@ module.exports = {
guest: {
cacheExpiration: moment.utc().subtract(1, 'd')
},
groups: {},
/**
* Initialize the authentication module
@ -22,23 +23,27 @@ module.exports = {
init() {
this.passport = passport
passport.serializeUser(function (user, done) {
passport.serializeUser((user, done) => {
done(null, user.id)
})
passport.deserializeUser(function (id, done) {
WIKI.models.users.query().findById(id).then((user) => {
passport.deserializeUser(async (id, done) => {
try {
const user = await WIKI.models.users.query().findById(id).modifyEager('groups', builder => {
builder.select('groups.id', 'permissions')
})
if (user) {
done(null, user)
} else {
done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
}
return true
}).catch((err) => {
} catch (err) {
done(err, null)
})
}
})
this.reloadGroups()
return this
},
@ -117,13 +122,14 @@ module.exports = {
res.cookie('jwt', newToken.token, { expires: moment().add(365, 'days').toDate() })
}
} catch (err) {
WIKI.logger.warn(err)
return next()
}
}
// JWT is NOT valid, set as guest
if (!user) {
if (WIKI.auth.guest.cacheExpiration ) {
if (true || WIKI.auth.guest.cacheExpiration.isSameOrBefore(moment.utc())) {
WIKI.auth.guest = await WIKI.models.users.getGuestUser()
WIKI.auth.guest.cacheExpiration = moment.utc().add(1, 'm')
}
@ -146,18 +152,99 @@ module.exports = {
* @param {Array<String>} permissions
* @param {String|Boolean} path
*/
checkAccess(user, permissions = [], path = false) {
checkAccess(user, permissions = [], page = false) {
// System Admin
if (_.includes(user.permissions, 'manage:system')) {
return true
}
const userPermissions = user.permissions ? user.permissions : user.getGlobalPermissions()
// Check Global Permissions
if (_.intersection(user.permissions, permissions).length < 1) {
if (_.intersection(userPermissions, permissions).length < 1) {
return false
}
console.info('---------------------')
// Check Page Rules
if (path && user.groups) {
let checkState = {
deny: false,
match: false,
specificity: ''
}
user.groups.forEach(grp => {
const grpId = _.isObject(grp) ? _.get(grp, 'id', 0) : grp
_.get(WIKI.auth.groups, `${grpId}.pageRules`, []).forEach(rule => {
console.info(page.path)
console.info(rule)
switch(rule.match) {
case 'START':
if (_.startsWith(`/${page.path}`, `/${rule.path}`)) {
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['END', 'REGEX', 'EXACT'] })
}
break
case 'END':
if (_.endsWith(page.path, rule.path)) {
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['REGEX', 'EXACT'] })
}
break
case 'REGEX':
const reg = new RegExp(rule.path)
if (reg.test(page.path)) {
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['EXACT'] })
}
case 'EXACT':
if (`/${page.path}` === `/${rule.path}`) {
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: [] })
}
break
}
})
})
console.info('DAKSJDHKASJD')
console.info(checkState)
return (checkState.match && !checkState.deny)
}
return false
},
/**
* Check and apply Page Rule specificity
*
* @access private
*/
_applyPageRuleSpecificity ({ rule, checkState, higherPriority = [] }) {
if (rule.path.length === checkState.specificity.length) {
// Do not override higher priority rules
if (_.includes(higherPriority, checkState.match)) {
return checkState
}
// Do not override a previous DENY rule with same match
if (rule.match === checkState.match && checkState.deny && !rule.deny) {
return checkState
}
} else if (rule.path.length < checkState.specificity.length) {
// Do not override higher specificity rules
return checkState
}
return {
deny: rule.deny,
match: rule.match,
specificity: rule.path
}
},
/**
* Reload Groups from DB
*/
async reloadGroups() {
const groupsArray = await WIKI.models.groups.query()
this.groups = _.keyBy(groupsArray, 'id')
}
}

View File

@ -2,41 +2,41 @@
/* global WIKI */
module.exports = {
Query: {
comments(obj, args, context, info) {
return WIKI.models.Comment.findAll({ where: args })
}
},
Mutation: {
createComment(obj, args) {
return WIKI.models.Comment.create({
content: args.content,
author: args.userId,
document: args.documentId
})
},
deleteComment(obj, args) {
return WIKI.models.Comment.destroy({
where: {
id: args.id
},
limit: 1
})
},
modifyComment(obj, args) {
return WIKI.models.Comment.update({
content: args.content
}, {
where: { id: args.id }
})
}
},
Comment: {
author(cm) {
return cm.getAuthor()
},
document(cm) {
return cm.getDocument()
}
}
// Query: {
// comments(obj, args, context, info) {
// return WIKI.models.Comment.findAll({ where: args })
// }
// },
// Mutation: {
// createComment(obj, args) {
// return WIKI.models.Comment.create({
// content: args.content,
// author: args.userId,
// document: args.documentId
// })
// },
// deleteComment(obj, args) {
// return WIKI.models.Comment.destroy({
// where: {
// id: args.id
// },
// limit: 1
// })
// },
// modifyComment(obj, args) {
// return WIKI.models.Comment.update({
// content: args.content
// }, {
// where: { id: args.id }
// })
// }
// },
// Comment: {
// author(cm) {
// return cm.getAuthor()
// },
// document(cm) {
// return cm.getDocument()
// }
// }
}

View File

@ -1,46 +0,0 @@
/* global WIKI */
module.exports = {
Query: {
documents(obj, args, context, info) {
return WIKI.models.Document.findAll({ where: args })
}
},
Mutation: {
createDocument(obj, args) {
return WIKI.models.Document.create(args)
},
deleteDocument(obj, args) {
return WIKI.models.Document.destroy({
where: {
id: args.id
},
limit: 1
})
},
modifyDocument(obj, args) {
return WIKI.models.Document.update({
title: args.title,
subtitle: args.subtitle
}, {
where: { id: args.id }
})
},
moveDocument(obj, args) {
return WIKI.models.Document.update({
path: args.path
}, {
where: { id: args.id }
})
}
},
Document: {
comments(doc) {
return doc.getComments()
},
tags(doc) {
return doc.getTags()
}
}
}

View File

@ -4,48 +4,48 @@
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()
}
}
// 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

@ -2,34 +2,34 @@
/* global WIKI */
module.exports = {
Query: {
folders(obj, args, context, info) {
return WIKI.models.Folder.findAll({ where: args })
}
},
Mutation: {
createFolder(obj, args) {
return WIKI.models.Folder.create(args)
},
deleteFolder(obj, args) {
return WIKI.models.Folder.destroy({
where: {
id: args.id
},
limit: 1
})
},
renameFolder(obj, args) {
return WIKI.models.Folder.update({
name: args.name
}, {
where: { id: args.id }
})
}
},
Folder: {
files(grp) {
return grp.getFiles()
}
}
// Query: {
// folders(obj, args, context, info) {
// return WIKI.models.Folder.findAll({ where: args })
// }
// },
// Mutation: {
// createFolder(obj, args) {
// return WIKI.models.Folder.create(args)
// },
// deleteFolder(obj, args) {
// return WIKI.models.Folder.destroy({
// where: {
// id: args.id
// },
// limit: 1
// })
// },
// renameFolder(obj, args) {
// return WIKI.models.Folder.update({
// name: args.name
// }, {
// where: { id: args.id }
// })
// }
// },
// Folder: {
// files(grp) {
// return grp.getFiles()
// }
// }
}

View File

@ -1,4 +1,5 @@
const graphHelper = require('../../helpers/graph')
const safeRegex = require('safe-regex')
/* global WIKI */
@ -44,6 +45,7 @@ module.exports = {
pageRules: JSON.stringify([]),
isSystem: false
})
await WIKI.auth.reloadGroups()
return {
responseResult: graphHelper.generateSuccess('Group created successfully.'),
group
@ -51,6 +53,7 @@ module.exports = {
},
async delete(obj, args) {
await WIKI.models.groups.query().deleteById(args.id)
await WIKI.auth.reloadGroups()
return {
responseResult: graphHelper.generateSuccess('Group has been deleted.')
}
@ -70,11 +73,20 @@ module.exports = {
}
},
async update(obj, args) {
if(_.some(args.pageRules, pr => {
return pr.match !== 'REGEX' || safeRegex(pr.path)
})) {
throw new gql.GraphQLError('Some Page Rules contains unsafe or exponential time regex.')
}
await WIKI.models.groups.query().patch({
name: args.name,
permissions: JSON.stringify(args.permissions),
pageRules: JSON.stringify(args.pageRules)
}).where('id', args.id)
await WIKI.auth.reloadGroups()
return {
responseResult: graphHelper.generateSuccess('Group has been updated.')
}

View File

@ -31,6 +31,9 @@ module.exports = {
namespacing: WIKI.config.lang.namespacing,
namespaces: WIKI.config.lang.namespaces
}
},
translations (obj, args, context, info) {
return WIKI.lang.getByNamespace(args.locale, args.namespace)
}
},
LocalizationMutation: {

View File

@ -16,15 +16,6 @@ module.exports = {
offsetPage: args.offsetPage || 0,
offsetSize: args.offsetSize || 100
})
},
async list(obj, args, context, info) {
return WIKI.models.pages.query().select(
'pages.*',
WIKI.models.pages.relatedQuery('users').count().as('userCount')
)
},
async single(obj, args, context, info) {
return WIKI.models.pages.query().findById(args.id)
}
},
PageMutation: {

View File

@ -1,53 +0,0 @@
/* global WIKI */
const gql = require('graphql')
module.exports = {
Query: {
rights(obj, args, context, info) {
return WIKI.models.Right.findAll({ where: args })
}
},
Mutation: {
addRightToGroup(obj, args) {
return WIKI.models.Group.findById(args.groupId).then(grp => {
if (!grp) {
throw new gql.GraphQLError('Invalid Group ID')
}
return WIKI.models.Right.create({
path: args.path,
role: args.role,
exact: args.exact,
allow: args.allow,
group: grp
})
})
},
removeRightFromGroup(obj, args) {
return WIKI.models.Right.destroy({
where: {
id: args.rightId
},
limit: 1
})
},
modifyRight(obj, args) {
return WIKI.models.Right.update({
path: args.path,
role: args.role,
exact: args.exact,
allow: args.allow
}, {
where: {
id: args.id
}
})
}
},
Right: {
group(rt) {
return rt.getGroup()
}
}
}

View File

@ -1,24 +0,0 @@
/* global WIKI */
const _ = require('lodash')
module.exports = {
Query: {
settings(obj, args, context, info) {
return WIKI.models.Setting.findAll({ where: args, raw: true }).then(entries => {
return _.map(entries, entry => {
entry.config = JSON.stringify(entry.config)
return entry
})
})
}
},
Mutation: {
setConfigEntry(obj, args) {
return WIKI.models.Setting.update({
value: args.value
}, { where: { key: args.key } })
}
}
}

View File

@ -20,13 +20,9 @@ module.exports = {
Query: {
async system() { return {} }
},
Mutation: {
async system() { return {} }
},
SystemQuery: {
async info() { return {} }
},
SystemMutation: { },
SystemInfo: {
configFile() {
return path.join(process.cwd(), 'config.yml')

View File

@ -4,60 +4,60 @@
const gql = require('graphql')
module.exports = {
Query: {
tags(obj, args, context, info) {
return WIKI.models.Tag.findAll({ where: args })
}
},
Mutation: {
assignTagToDocument(obj, args) {
return WIKI.models.Tag.findById(args.tagId).then(tag => {
if (!tag) {
throw new gql.GraphQLError('Invalid Tag ID')
}
return WIKI.models.Document.findById(args.documentId).then(doc => {
if (!doc) {
throw new gql.GraphQLError('Invalid Document ID')
}
return tag.addDocument(doc)
})
})
},
createTag(obj, args) {
return WIKI.models.Tag.create(args)
},
deleteTag(obj, args) {
return WIKI.models.Tag.destroy({
where: {
id: args.id
},
limit: 1
})
},
removeTagFromDocument(obj, args) {
return WIKI.models.Tag.findById(args.tagId).then(tag => {
if (!tag) {
throw new gql.GraphQLError('Invalid Tag ID')
}
return WIKI.models.Document.findById(args.documentId).then(doc => {
if (!doc) {
throw new gql.GraphQLError('Invalid Document ID')
}
return tag.removeDocument(doc)
})
})
},
renameTag(obj, args) {
return WIKI.models.Group.update({
key: args.key
}, {
where: { id: args.id }
})
}
},
Tag: {
documents(tag) {
return tag.getDocuments()
}
}
// Query: {
// tags(obj, args, context, info) {
// return WIKI.models.Tag.findAll({ where: args })
// }
// },
// Mutation: {
// assignTagToDocument(obj, args) {
// return WIKI.models.Tag.findById(args.tagId).then(tag => {
// if (!tag) {
// throw new gql.GraphQLError('Invalid Tag ID')
// }
// return WIKI.models.Document.findById(args.documentId).then(doc => {
// if (!doc) {
// throw new gql.GraphQLError('Invalid Document ID')
// }
// return tag.addDocument(doc)
// })
// })
// },
// createTag(obj, args) {
// return WIKI.models.Tag.create(args)
// },
// deleteTag(obj, args) {
// return WIKI.models.Tag.destroy({
// where: {
// id: args.id
// },
// limit: 1
// })
// },
// removeTagFromDocument(obj, args) {
// return WIKI.models.Tag.findById(args.tagId).then(tag => {
// if (!tag) {
// throw new gql.GraphQLError('Invalid Tag ID')
// }
// return WIKI.models.Document.findById(args.documentId).then(doc => {
// if (!doc) {
// throw new gql.GraphQLError('Invalid Document ID')
// }
// return tag.removeDocument(doc)
// })
// })
// },
// renameTag(obj, args) {
// return WIKI.models.Group.update({
// key: args.key
// }, {
// where: { id: args.id }
// })
// }
// },
// Tag: {
// documents(tag) {
// return tag.getDocuments()
// }
// }
}

View File

@ -1,12 +0,0 @@
/* global WIKI */
module.exports = {
Query: {
translations (obj, args, context, info) {
return WIKI.lang.getByNamespace(args.locale, args.namespace)
}
},
Mutation: {},
Translation: {}
}

View File

@ -22,7 +22,6 @@ module.exports = {
},
async single(obj, args, context, info) {
let usr = await WIKI.models.users.query().findById(args.id)
console.info(usr)
usr.password = ''
usr.tfaSecret = ''
return usr

View File

@ -1,37 +1,32 @@
# ENUMS
enum FileType {
binary
image
}
enum RightRole {
read
write
manage
}
# ====================== #
# Wiki.js GraphQL Schema #
# ====================== #
# DIRECTIVES
# ----------
directive @auth(requires: [String]) on QUERY | FIELD_DEFINITION | ARGUMENT_DEFINITION
# TYPES
# -----
# Generic Key Value Pair
type KeyValuePair {
key: String!
value: String!
}
# General Key Value Pair Input
input KeyValuePairInput {
key: String!
value: String!
}
# Generic Mutation Response
type DefaultResponse {
responseResult: ResponseStatus
}
# Mutation Status
type ResponseStatus {
succeeded: Boolean!
errorCode: Int!
@ -39,220 +34,14 @@ type ResponseStatus {
message: String
}
type Comment {
id: Int!
createdAt: Date
updatedAt: Date
content: String
document: Document!
author: User!
}
type Document {
id: Int!
createdAt: Date
updatedAt: Date
path: String!
title: String!
subtitle: String
parentPath: String
parentTitle: String
isDirectory: Boolean!
isEntry: Boolean!
searchContent: String
comments: [Comment]
tags: [Tag]
}
type File {
id: Int!
createdAt: Date
updatedAt: Date
category: FileType!
mime: String!
extra: String
filename: String!
basename: String!
filesize: Int!
folder: Folder
}
type Folder {
id: Int!
createdAt: Date
updatedAt: Date
name: String!
files: [File]
}
type Right {
id: Int!
createdAt: Date
updatedAt: Date
path: String!
role: RightRole!
exact: Boolean!
allow: Boolean!
group: Group!
}
type Setting {
id: Int!
createdAt: Date
updatedAt: Date
key: String!
config: String!
}
# Tags are attached to one or more documents
type Tag {
id: Int!
createdAt: Date
updatedAt: Date
key: String!
documents: [Document]
}
type Translation {
key: String!
value: String!
}
type OperationResult {
succeeded: Boolean!
message: String
data: String
}
# ROOT
# ----
# Query (Read)
type Query {
comments(id: Int): [Comment]
documents(id: Int, path: String): [Document]
files(id: Int): [File]
folders(id: Int, name: String): [Folder]
rights(id: Int): [Right]
settings(key: String): [Setting]
tags(key: String): [Tag]
translations(locale: String!, namespace: String!): [Translation]
}
type Query
# Mutations (Create, Update, Delete)
type Mutation {
addRightToGroup(
groupId: Int!
path: String!
role: RightRole!
exact: Boolean!
allow: Boolean!
): Right
assignTagToDocument(
tagId: Int!
documentId: Int!
): OperationResult
createComment(
userId: Int!
documentId: Int!
content: String!
): Comment
createDocument(
path: String!
title: String!
subtitle: String
): Document
createFolder(
name: String!
): Folder
createTag(
name: String!
): Tag
deleteComment(
id: Int!
): OperationResult
deleteDocument(
id: Int!
): OperationResult
deleteFile(
id: Int!
): OperationResult
deleteFolder(
id: Int!
): OperationResult
deleteTag(
id: Int!
): OperationResult
modifyComment(
id: Int!
content: String!
): Document
modifyDocument(
id: Int!
title: String
subtitle: String
): Document
modifyRight(
id: Int!
path: String
role: RightRole
exact: Boolean
allow: Boolean
): Right
moveDocument(
id: Int!
path: String!
): OperationResult
moveFile(
id: Int!
folderId: Int!
): OperationResult
renameFile(
id: Int!
name: String!
): OperationResult
renameFolder(
id: Int!
name: String!
): OperationResult
renameTag(
id: Int!
key: String!
): OperationResult
removeTagFromDocument(
tagId: Int!
documentId: Int!
): OperationResult
removeRightFromGroup(
rightId: Int!
): OperationResult
setConfigEntry(
key: String!
value: String!
): OperationResult
uploadFile(
category: FileType!
filename: String!
): File
}
type Mutation
# Subscriptions (Push, Real-time)
type Subscription

View File

@ -89,7 +89,7 @@ type PageRule {
id: String!
deny: Boolean!
match: PageRuleMatch!
roles: [PageRuleRole]!
roles: [String]!
path: String!
locales: [String]!
}
@ -98,24 +98,11 @@ input PageRuleInput {
id: String!
deny: Boolean!
match: PageRuleMatch!
roles: [PageRuleRole]!
roles: [String]!
path: String!
locales: [String]!
}
enum PageRuleRole {
READ
WRITE
MANAGE
DELETE
AS_READ
AS_WRITE
AS_MANAGE
CM_READ
CM_WRITE
CM_MANAGE
}
enum PageRuleMatch {
START
EXACT

View File

@ -17,6 +17,7 @@ extend type Mutation {
type LocalizationQuery {
locales: [LocalizationLocale]
config: LocalizationConfig
translations(locale: String!, namespace: String!): [Translation]
}
# -----------------------------------------------
@ -57,3 +58,8 @@ type LocalizationConfig {
namespacing: Boolean!
namespaces: [String]!
}
type Translation {
key: String!
value: String!
}

View File

@ -19,19 +19,7 @@ type PageQuery {
id: Int!
offsetPage: Int
offsetSize: Int
): PageHistoryResult
list(
filter: String
orderBy: String
): [PageMinimal]
single(
id: Int
path: String
locale: String
isPrivate: Boolean
): Page
): PageHistoryResult @auth(requires: ["manage:system", "read:pages"])
}
# -----------------------------------------------
@ -82,21 +70,8 @@ type PageResponse {
page: Page
}
type PageMinimal {
id: Int!
name: String!
userCount: Int
createdAt: Date!
updatedAt: Date!
}
type Page {
id: Int!
name: String!
rights: [Right]
users: [User]
createdAt: Date!
updatedAt: Date!
}
type PageHistory {

View File

@ -49,7 +49,7 @@ type SiteConfig {
description: String!
robots: [String]!
analyticsService: String!
analyticsId: String!
analyticsId: String!
company: String!
hasLogo: Boolean!
logoIsSquare: Boolean!

View File

@ -6,50 +6,42 @@ extend type Query {
system: SystemQuery
}
extend type Mutation {
system: SystemMutation
}
# -----------------------------------------------
# QUERIES
# -----------------------------------------------
type SystemQuery {
info: SystemInfo @auth(requires: ["manage:system"])
info: SystemInfo
}
# -----------------------------------------------
# MUTATIONS
# -----------------------------------------------
type SystemMutation {
todo: String
}
# -----------------------------------------------
# TYPES
# -----------------------------------------------
type SystemInfo {
configFile: String
cpuCores: Int
currentVersion: String
dbHost: String
dbType: String
dbVersion: String
groupsTotal: Int
hostname: String
latestVersion: String
latestVersionReleaseDate: Date
nodeVersion: String
operatingSystem: String
pagesTotal: Int
platform: String
ramTotal: String
redisHost: String
redisTotalRAM: String
redisUsedRAM: String
redisVersion: String
usersTotal: Int
workingDirectory: String
configFile: String @auth(requires: ["manage:system"])
cpuCores: Int @auth(requires: ["manage:system"])
currentVersion: String @auth(requires: ["manage:system"])
dbHost: String @auth(requires: ["manage:system"])
dbType: String @auth(requires: ["manage:system"])
dbVersion: String @auth(requires: ["manage:system"])
groupsTotal: Int @auth(requires: ["manage:system", "manage:navigation", "manage:groups", "write:groups", "manage:users", "write:users"])
hostname: String @auth(requires: ["manage:system"])
latestVersion: String @auth(requires: ["manage:system"])
latestVersionReleaseDate: Date @auth(requires: ["manage:system"])
nodeVersion: String @auth(requires: ["manage:system"])
operatingSystem: String @auth(requires: ["manage:system"])
pagesTotal: Int @auth(requires: ["manage:system", "manage:navigation", "manage:pages", "delete:pages"])
platform: String @auth(requires: ["manage:system"])
ramTotal: String @auth(requires: ["manage:system"])
redisHost: String @auth(requires: ["manage:system"])
redisTotalRAM: String @auth(requires: ["manage:system"])
redisUsedRAM: String @auth(requires: ["manage:system"])
redisVersion: String @auth(requires: ["manage:system"])
usersTotal: Int @auth(requires: ["manage:system", "manage:navigation", "manage:groups", "write:groups", "manage:users", "write:users"])
workingDirectory: String @auth(requires: ["manage:system"])
}

View File

@ -101,6 +101,10 @@ module.exports = class User extends Model {
await this.generateHash()
}
// ------------------------------------------------
// Instance Methods
// ------------------------------------------------
async generateHash() {
if (this.password) {
if (bcryptRegexp.test(this.password)) { return }
@ -138,11 +142,18 @@ module.exports = class User extends Model {
return (result && _.has(result, 'delta') && result.delta === 0)
}
async getPermissions() {
const permissions = await this.$relatedQuery('groups').select('permissions').pluck('permissions')
this.permissions = _.uniq(_.flatten(permissions))
getGlobalPermissions() {
return _.uniq(_.flatten(_.map(this.groups, 'permissions')))
}
getGroups() {
return _.uniq(_.map(this.groups, 'id'))
}
// ------------------------------------------------
// Model Methods
// ------------------------------------------------
static async processProfile(profile) {
let primaryEmail = ''
if (_.isArray(profile.emails)) {
@ -246,12 +257,17 @@ module.exports = class User extends Model {
static async refreshToken(user) {
if (_.isSafeInteger(user)) {
user = await WIKI.models.users.query().findById(user)
user = await WIKI.models.users.query().findById(user).eager('groups').modifyEager('groups', builder => {
builder.select('groups.id', 'permissions')
})
if (!user) {
WIKI.logger.warn(`Failed to refresh token for user ${user}: Not found.`)
throw new WIKI.Error.AuthGenericError()
}
} else if(_.isNil(user.groups)) {
await user.$relatedQuery('groups').select('groups.id', 'permissions')
}
return {
token: jwt.sign({
id: user.id,
@ -261,7 +277,8 @@ module.exports = class User extends Model {
timezone: user.timezone,
localeCode: user.localeCode,
defaultEditor: user.defaultEditor,
permissions: ['manage:system']
permissions: user.getGlobalPermissions(),
groups: user.getGroups()
}, {
key: WIKI.config.certs.private,
passphrase: WIKI.config.sessionSecret
@ -398,8 +415,13 @@ module.exports = class User extends Model {
}
static async getGuestUser () {
let user = await WIKI.models.users.query().findById(2)
user.getPermissions()
const user = await WIKI.models.users.query().findById(2).eager('groups').modifyEager('groups', builder => {
builder.select('groups.id', 'permissions')
})
if (!user) {
WIKI.logger.error('CRITICAL ERROR: Guest user is missing!')
process.exit(1)
}
return user
}
}

View File

@ -219,9 +219,9 @@ module.exports = () => {
})
const guestGroup = await WIKI.models.groups.query().insert({
name: 'Guests',
permissions: JSON.stringify(['read:pages']),
permissions: JSON.stringify(['read:pages', 'read:assets', 'read:comments']),
pageRules: JSON.stringify([
{ id: 'guest', roles: ['READ', 'AS_READ', 'CM_READ'], match: 'START', deny: false, path: '', locales: [] }
{ id: 'guest', roles: ['read:pages', 'read:assets', 'read:comments'], match: 'START', deny: false, path: '', locales: [] }
]),
isSystem: true
})

13
server/views/notfound.pug Normal file
View File

@ -0,0 +1,13 @@
extends master.pug
block body
#root.is-fullscreen
v-app
.notfound
.notfound-content
img.animated.fadeIn(src='/svg/icon-delete-file.svg', alt='Not Found')
.headline= t('notfound.title')
.subheading.mt-3= t('notfound.subtitle')
v-btn.mt-5(color='red lighten-4', href='/', large, outline)
v-icon(left) home
span= t('notfound.gohome')