refactor: moved server content to /server

This commit is contained in:
NGPixel
2017-04-28 17:58:38 -04:00
parent 86eb7a427d
commit d4b73be1e7
97 changed files with 84 additions and 73 deletions
+200
View File
@@ -0,0 +1,200 @@
// ===========================================
// Wiki.js - Background Agent
// 1.0.0
// Licensed under AGPLv3
// ===========================================
const path = require('path')
const ROOTPATH = process.cwd()
const SERVERPATH = path.join(ROOTPATH, 'server')
global.PROCNAME = 'AGENT'
global.ROOTPATH = ROOTPATH
global.SERVERPATH = SERVERPATH
global.IS_DEBUG = process.env.NODE_ENV === 'development'
let appconf = require('./libs/config')()
global.appconfig = appconf.config
global.appdata = appconf.data
// ----------------------------------------
// Load Winston
// ----------------------------------------
global.winston = require('./libs/logger')(IS_DEBUG)
// ----------------------------------------
// Load global modules
// ----------------------------------------
winston.info('[AGENT] Background Agent is initializing...')
global.db = require('./libs/db').init()
global.upl = require('./libs/uploads-agent').init()
global.git = require('./libs/git').init()
global.entries = require('./libs/entries').init()
global.mark = require('./libs/markdown')
// ----------------------------------------
// Load modules
// ----------------------------------------
const moment = require('moment')
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const klaw = require('klaw')
const Cron = require('cron').CronJob
// ----------------------------------------
// Start Cron
// ----------------------------------------
let job
let jobIsBusy = false
let jobUplWatchStarted = false
db.onReady.then(() => {
return db.Entry.remove({})
}).then(() => {
job = new Cron({
cronTime: '0 */5 * * * *',
onTick: () => {
// Make sure we don't start two concurrent jobs
if (jobIsBusy) {
winston.warn('[AGENT] Previous job has not completed gracefully or is still running! Skipping for now. (This is not normal, you should investigate)')
return
}
winston.info('[AGENT] Running all jobs...')
jobIsBusy = true
// Prepare async job collector
let jobs = []
let repoPath = path.resolve(ROOTPATH, appconfig.paths.repo)
let dataPath = path.resolve(ROOTPATH, appconfig.paths.data)
let uploadsTempPath = path.join(dataPath, 'temp-upload')
// ----------------------------------------
// REGULAR JOBS
// ----------------------------------------
//* ****************************************
// -> Sync with Git remote
//* ****************************************
jobs.push(git.resync().then(() => {
// -> Stream all documents
let cacheJobs = []
let jobCbStreamDocsResolve = null
let jobCbStreamDocs = new Promise((resolve, reject) => {
jobCbStreamDocsResolve = resolve
})
klaw(repoPath).on('data', function (item) {
if (path.extname(item.path) === '.md' && path.basename(item.path) !== 'README.md') {
let entryPath = entries.parsePath(entries.getEntryPathFromFullPath(item.path))
let cachePath = entries.getCachePath(entryPath)
// -> Purge outdated cache
cacheJobs.push(
fs.statAsync(cachePath).then((st) => {
return moment(st.mtime).isBefore(item.stats.mtime) ? 'expired' : 'active'
}).catch((err) => {
return (err.code !== 'EEXIST') ? err : 'new'
}).then((fileStatus) => {
// -> Delete expired cache file
if (fileStatus === 'expired') {
return fs.unlinkAsync(cachePath).return(fileStatus)
}
return fileStatus
}).then((fileStatus) => {
// -> Update cache and search index
if (fileStatus !== 'active') {
return entries.updateCache(entryPath).then(entry => {
process.send({
action: 'searchAdd',
content: entry
})
return true
})
}
return true
})
)
}
}).on('end', () => {
jobCbStreamDocsResolve(Promise.all(cacheJobs))
})
return jobCbStreamDocs
}))
//* ****************************************
// -> Clear failed temporary upload files
//* ****************************************
jobs.push(
fs.readdirAsync(uploadsTempPath).then((ls) => {
let fifteenAgo = moment().subtract(15, 'minutes')
return Promise.map(ls, (f) => {
return fs.statAsync(path.join(uploadsTempPath, f)).then((s) => { return { filename: f, stat: s } })
}).filter((s) => { return s.stat.isFile() }).then((arrFiles) => {
return Promise.map(arrFiles, (f) => {
if (moment(f.stat.ctime).isBefore(fifteenAgo, 'minute')) {
return fs.unlinkAsync(path.join(uploadsTempPath, f.filename))
} else {
return true
}
})
})
})
)
// ----------------------------------------
// Run
// ----------------------------------------
Promise.all(jobs).then(() => {
winston.info('[AGENT] All jobs completed successfully! Going to sleep for now.')
if (!jobUplWatchStarted) {
jobUplWatchStarted = true
upl.initialScan().then(() => {
job.start()
})
}
return true
}).catch((err) => {
winston.error('[AGENT] One or more jobs have failed: ', err)
}).finally(() => {
jobIsBusy = false
})
},
start: false,
timeZone: 'UTC',
runOnInit: true
})
})
// ----------------------------------------
// Shutdown gracefully
// ----------------------------------------
process.on('disconnect', () => {
winston.warn('[AGENT] Lost connection to main server. Exiting...')
job.stop()
process.exit()
})
process.on('exit', () => {
job.stop()
})
+4
View File
@@ -0,0 +1,4 @@
<!-- TITLE: {TITLE} -->
<!-- SUBTITLE: A quick summary of {TITLE} -->
# Header
+85
View File
@@ -0,0 +1,85 @@
# ---------------------------------
# DO NOT EDIT THIS FILE!
# This is reserved for system use!
# ---------------------------------
name: Wiki.js
capabilities:
guest: true
rights: true
manyAuthProviders: true
defaults:
config:
title: Wiki
host: http://localhost
port: 80
paths:
repo: ./repo
data: ./data
uploads:
maxImageFileSize: 3,
maxOtherFileSize: 100
lang: en
public: false
auth:
defaultReadAccess: false
local:
enabled: true
microsoft:
enabled: false
google:
enabled: false
facebook:
enabled: false
github:
enabled: false
slack:
enabled: false
ldap:
enabled: false
azure:
enabled: false
db: mongodb://localhost/wiki
sessionSecret: null
admin: null
git:
url: null
branch: master
auth:
type: basic
username: null
password: null
privateKey: null
sslVerify: true
serverEmail: wiki@example.com
showUserEmail: true
features:
mathjax: true
externalLogging:
bugsnap: false
loggly: false
papertrail: false
rollbar: false
sentry: false
langs:
-
id: en
name: English
-
id: fr
name: French - Français
-
id: de
name: German - Deutsch
-
id: ko
name: Korean - 한국어
-
id: pt
name: Portuguese - Português
-
id: ru
name: Russian - Русский
-
id: es
name: Spanish - Español
# ---------------------------------
+9
View File
@@ -0,0 +1,9 @@
'use strict'
module.exports = {
arabic: /([\u0600-\u06ff]|[\u0750-\u077f]|[\ufb50-\ufc3f]|[\ufe70-\ufefc])/,
cjk: /([\u4E00-\u9FBF]|[\u3040-\u309F\u30A0-\u30FF]|[ㄱ-ㅎ가-힣ㅏ-ㅣ])/,
youtube: /(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v(?:i)?=|&v(?:i)?=))([^#&?]*).*/,
vimeo: /vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/(?:[^/]*)\/videos\/|album\/(?:\d+)\/video\/|)(\d+)(?:$|\/|\?)/,
dailymotion: /(?:dailymotion\.com(?:\/embed)?(?:\/video|\/hub)|dai\.ly)\/([0-9a-z]+)(?:[-_0-9a-zA-Z]+(?:#video=)?([a-z0-9]+)?)?/
}
+452
View File
@@ -0,0 +1,452 @@
'use strict'
module.exports = (port, spinner) => {
const path = require('path')
const ROOTPATH = process.cwd()
const SERVERPATH = path.join(ROOTPATH, 'server')
const IS_DEBUG = process.env.NODE_ENV === 'development'
// ----------------------------------------
// Load modules
// ----------------------------------------
const bodyParser = require('body-parser')
const compression = require('compression')
const express = require('express')
const favicon = require('serve-favicon')
const http = require('http')
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const yaml = require('js-yaml')
const _ = require('lodash')
// ----------------------------------------
// Define Express App
// ----------------------------------------
var app = express()
app.use(compression())
var server
// ----------------------------------------
// Public Assets
// ----------------------------------------
app.use(favicon(path.join(ROOTPATH, 'assets', 'favicon.ico')))
app.use(express.static(path.join(ROOTPATH, 'assets')))
// ----------------------------------------
// View Engine Setup
// ----------------------------------------
app.set('views', path.join(SERVERPATH, 'views'))
app.set('view engine', 'pug')
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: false }))
app.locals._ = require('lodash')
// ----------------------------------------
// Controllers
// ----------------------------------------
app.get('*', (req, res) => {
let langs = []
let conf = {}
try {
langs = yaml.safeLoad(fs.readFileSync(path.join(SERVERPATH, 'app/data.yml'), 'utf8')).langs
conf = yaml.safeLoad(fs.readFileSync(path.join(ROOTPATH, 'config.yml'), 'utf8'))
} catch (err) {
console.error(err)
}
res.render('configure/index', { langs, conf })
})
/**
* Perform basic system checks
*/
app.post('/syscheck', (req, res) => {
Promise.mapSeries([
() => {
const semver = require('semver')
if (!semver.satisfies(semver.clean(process.version), '>=4.6.0')) {
throw new Error('Node.js version is too old. Minimum is v4.6.0.')
}
return 'Node.js ' + process.version + ' detected. Minimum is v4.6.0.'
},
() => {
return Promise.try(() => {
require('crypto')
}).catch(err => { // eslint-disable-line handle-callback-err
throw new Error('Crypto Node.js module is not available.')
}).return('Node.js Crypto module is available.')
},
() => {
const exec = require('child_process').exec
const semver = require('semver')
return new Promise((resolve, reject) => {
exec('git --version', (err, stdout, stderr) => {
if (err || stdout.length < 3) {
reject(new Error('Git is not installed or not reachable from PATH.'))
}
let gitver = _.head(stdout.match(/[\d]+\.[\d]+\.[\d]+/g))
if (!semver.satisfies(semver.clean(gitver), '>=2.7.4')) {
reject(new Error('Git version is too old. Minimum is v2.7.4.'))
}
resolve('Git v' + gitver + ' detected. Minimum is v2.7.4.')
})
})
},
() => {
const os = require('os')
if (os.totalmem() < 1024 * 1024 * 768) {
throw new Error('Not enough memory. Minimum is 768 MB.')
}
return _.round(os.totalmem() / (1024 * 1024)) + ' MB of system memory available. Minimum is 768 MB.'
},
() => {
let fs = require('fs')
return Promise.try(() => {
fs.accessSync(path.join(ROOTPATH, 'config.yml'), (fs.constants || fs).W_OK)
}).catch(err => { // eslint-disable-line handle-callback-err
throw new Error('config.yml file is not writable by Node.js process or was not created properly.')
}).return('config.yml is writable by the setup process.')
}
], test => { return test() }).then(results => {
res.json({ ok: true, results })
}).catch(err => {
res.json({ ok: false, error: err.message })
})
})
/**
* Check the DB connection
*/
app.post('/dbcheck', (req, res) => {
let mongo = require('mongodb').MongoClient
let mongoURI = (_.startsWith(req.body.db, '$')) ? process.env[req.body.db.slice(1)] : req.body.db
mongo.connect(mongoURI, {
autoReconnect: false,
reconnectTries: 2,
reconnectInterval: 1000,
connectTimeoutMS: 5000,
socketTimeoutMS: 5000
}, (err, db) => {
if (err === null) {
// Try to create a test collection
db.createCollection('test', (err, results) => {
if (err === null) {
// Try to drop test collection
db.dropCollection('test', (err, results) => {
if (err === null) {
res.json({ ok: true })
} else {
res.json({ ok: false, error: 'Unable to delete test collection. Verify permissions. ' + err.message })
}
db.close()
})
} else {
res.json({ ok: false, error: 'Unable to create test collection. Verify permissions. ' + err.message })
db.close()
}
})
} else {
res.json({ ok: false, error: err.message })
}
})
})
/**
* Check the Git connection
*/
app.post('/gitcheck', (req, res) => {
const exec = require('execa')
const url = require('url')
const dataDir = path.resolve(ROOTPATH, req.body.pathData)
const gitDir = path.resolve(ROOTPATH, req.body.pathRepo)
let gitRemoteUrl = ''
if (req.body.gitUseRemote === true) {
let urlObj = url.parse(req.body.gitUrl)
if (req.body.gitAuthType === 'basic') {
urlObj.auth = req.body.gitAuthUser + ':' + req.body.gitAuthPass
}
gitRemoteUrl = url.format(urlObj)
}
Promise.mapSeries([
() => {
return fs.ensureDirAsync(dataDir).return('Data directory path is valid.')
},
() => {
return fs.ensureDirAsync(gitDir).return('Git directory path is valid.')
},
() => {
return exec.stdout('git', ['init'], { cwd: gitDir }).then(result => {
return 'Local git repository has been initialized.'
})
},
() => {
if (req.body.gitUseRemote === false) { return false }
return exec.stdout('git', ['config', '--local', 'user.name', req.body.gitSignatureName], { cwd: gitDir }).then(result => {
return 'Git Signature Name has been set successfully.'
})
},
() => {
if (req.body.gitUseRemote === false) { return false }
return exec.stdout('git', ['config', '--local', 'user.email', req.body.gitSignatureEmail], { cwd: gitDir }).then(result => {
return 'Git Signature Name has been set successfully.'
})
},
() => {
if (req.body.gitUseRemote === false) { return false }
return exec.stdout('git', ['config', '--local', '--bool', 'http.sslVerify', req.body.gitAuthSSL], { cwd: gitDir }).then(result => {
return 'Git SSL Verify flag has been set successfully.'
})
},
() => {
if (req.body.gitUseRemote === false) { return false }
if (req.body.gitAuthType === 'ssh') {
return exec.stdout('git', ['config', '--local', 'core.sshCommand', 'ssh -i "' + req.body.gitAuthSSHKey + '" -o StrictHostKeyChecking=no'], { cwd: gitDir }).then(result => {
return 'Git SSH Private Key path has been set successfully.'
})
} else {
return false
}
},
() => {
if (req.body.gitUseRemote === false) { return false }
return exec.stdout('git', ['remote', 'remove', 'origin'], { cwd: gitDir }).catch(err => {
if (_.includes(err.message, 'No such remote')) {
return true
} else {
throw err
}
}).then(() => {
return exec.stdout('git', ['remote', 'add', 'origin', gitRemoteUrl], { cwd: gitDir }).then(result => {
return 'Git Remote was added successfully.'
})
})
},
() => {
if (req.body.gitUseRemote === false) { return false }
return exec.stdout('git', ['pull', 'origin', req.body.gitBranch], { cwd: gitDir }).then(result => {
return 'Git Pull operation successful.'
})
}
], step => { return step() }).then(results => {
return res.json({ ok: true, results: _.without(results, false) })
}).catch(err => {
let errMsg = (err.stderr) ? err.stderr.replace(/(error:|warning:|fatal:)/gi, '').replace(/ \s+/g, ' ') : err.message
res.json({ ok: false, error: errMsg })
})
})
/**
* Finalize
*/
app.post('/finalize', (req, res) => {
const bcrypt = require('bcryptjs-then')
const crypto = Promise.promisifyAll(require('crypto'))
let mongo = require('mongodb').MongoClient
Promise.join(
new Promise((resolve, reject) => {
mongo.connect(req.body.db, {
autoReconnect: false,
reconnectTries: 2,
reconnectInterval: 1000,
connectTimeoutMS: 5000,
socketTimeoutMS: 5000
}, (err, db) => {
if (err === null) {
db.createCollection('users', { strict: false }, (err, results) => {
if (err === null) {
bcrypt.hash(req.body.adminPassword).then(adminPwdHash => {
db.collection('users').findOneAndUpdate({
provider: 'local',
email: req.body.adminEmail
}, {
provider: 'local',
email: req.body.adminEmail,
name: 'Administrator',
password: adminPwdHash,
rights: [{
role: 'admin',
path: '/',
exact: false,
deny: false
}],
updatedAt: new Date(),
createdAt: new Date()
}, {
upsert: true,
returnOriginal: false
}, (err, results) => {
if (err === null) {
resolve(true)
} else {
reject(err)
}
db.close()
})
})
} else {
reject(err)
db.close()
}
})
} else {
reject(err)
}
})
}),
fs.readFileAsync(path.join(ROOTPATH, 'config.yml'), 'utf8').then(confRaw => {
let conf = yaml.safeLoad(confRaw)
conf.title = req.body.title
conf.host = req.body.host
conf.port = req.body.port
conf.paths = {
repo: req.body.pathRepo,
data: req.body.pathData
}
conf.uploads = {
maxImageFileSize: (conf.uploads && _.isNumber(conf.uploads.maxImageFileSize)) ? conf.uploads.maxImageFileSize : 3,
maxOtherFileSize: (conf.uploads && _.isNumber(conf.uploads.maxOtherFileSize)) ? conf.uploads.maxOtherFileSize : 100
}
conf.lang = req.body.lang
conf.public = (conf.public === true)
if (conf.auth && conf.auth.local) {
conf.auth.local = { enabled: true }
} else {
conf.auth = { local: { enabled: true } }
}
conf.db = req.body.db
if (req.body.gitUseRemote === false) {
conf.git = false
} else {
conf.git = {
url: req.body.gitUrl,
branch: req.body.gitBranch,
auth: {
type: req.body.gitAuthType,
username: req.body.gitAuthUser,
password: req.body.gitAuthPass,
privateKey: req.body.gitAuthSSHKey,
sslVerify: (req.body.gitAuthSSL === true)
},
signature: {
name: req.body.gitSignatureName,
email: req.body.gitSignatureEmail
}
}
}
return crypto.randomBytesAsync(32).then(buf => {
conf.sessionSecret = buf.toString('hex')
confRaw = yaml.safeDump(conf)
return fs.writeFileAsync(path.join(ROOTPATH, 'config.yml'), confRaw)
})
})
).then(() => {
if (process.env.IS_HEROKU) {
return fs.outputJsonAsync(path.join(SERVERPATH, 'app/heroku.json'), { configured: true })
} else {
return true
}
}).then(() => {
res.json({ ok: true })
}).catch(err => {
res.json({ ok: false, error: err.message })
})
})
/**
* Restart in normal mode
*/
app.post('/restart', (req, res) => {
res.status(204).end()
server.destroy(() => {
spinner.text = 'Setup wizard terminated. Restarting in normal mode...'
_.delay(() => {
const exec = require('execa')
exec.stdout('node', ['wiki', 'start']).then(result => {
spinner.succeed('Wiki.js is now running in normal mode!')
process.exit(0)
})
}, 1000)
})
})
// ----------------------------------------
// Error handling
// ----------------------------------------
app.use(function (req, res, next) {
var err = new Error('Not Found')
err.status = 404
next(err)
})
app.use(function (err, req, res, next) {
res.status(err.status || 500)
res.send({
message: err.message,
error: IS_DEBUG ? err : {}
})
spinner.fail(err.message)
process.exit(1)
})
// ----------------------------------------
// Start HTTP server
// ----------------------------------------
spinner.text = 'Starting HTTP server...'
app.set('port', port)
server = http.createServer(app)
server.listen(port)
var openConnections = []
server.on('connection', (conn) => {
let key = conn.remoteAddress + ':' + conn.remotePort
openConnections[key] = conn
conn.on('close', () => {
delete openConnections[key]
})
})
server.destroy = (cb) => {
server.close(cb)
for (let key in openConnections) {
openConnections[key].destroy()
}
}
server.on('error', (error) => {
if (error.syscall !== 'listen') {
throw error
}
switch (error.code) {
case 'EACCES':
spinner.fail('Listening on port ' + port + ' requires elevated privileges!')
return process.exit(1)
case 'EADDRINUSE':
spinner.fail('Port ' + port + ' is already in use!')
return process.exit(1)
default:
throw error
}
})
server.on('listening', () => {
spinner.text = 'Browse to http://localhost:' + port + ' to configure Wiki.js!'
})
}
+250
View File
@@ -0,0 +1,250 @@
'use strict'
var express = require('express')
var router = express.Router()
const Promise = require('bluebird')
const validator = require('validator')
const _ = require('lodash')
const axios = require('axios')
const path = require('path')
const fs = Promise.promisifyAll(require('fs-extra'))
/**
* Admin
*/
router.get('/', (req, res) => {
res.redirect('/admin/profile')
})
router.get('/profile', (req, res) => {
if (res.locals.isGuest) {
return res.render('error-forbidden')
}
res.render('pages/admin/profile', { adminTab: 'profile' })
})
router.post('/profile', (req, res) => {
if (res.locals.isGuest) {
return res.render('error-forbidden')
}
return db.User.findById(req.user.id).then((usr) => {
usr.name = _.trim(req.body.name)
if (usr.provider === 'local' && req.body.password !== '********') {
let nPwd = _.trim(req.body.password)
if (nPwd.length < 6) {
return Promise.reject(new Error('New Password too short!'))
} else {
return db.User.hashPassword(nPwd).then((pwd) => {
usr.password = pwd
return usr.save()
})
}
} else {
return usr.save()
}
}).then(() => {
return res.json({ msg: 'OK' })
}).catch((err) => {
res.status(400).json({ msg: err.message })
})
})
router.get('/stats', (req, res) => {
if (res.locals.isGuest) {
return res.render('error-forbidden')
}
Promise.all([
db.Entry.count(),
db.UplFile.count(),
db.User.count()
]).spread((totalEntries, totalUploads, totalUsers) => {
return res.render('pages/admin/stats', {
totalEntries, totalUploads, totalUsers, adminTab: 'stats'
}) || true
}).catch((err) => {
throw err
})
})
router.get('/users', (req, res) => {
if (!res.locals.rights.manage) {
return res.render('error-forbidden')
}
db.User.find({})
.select('-password -rights')
.sort('name email')
.exec().then((usrs) => {
res.render('pages/admin/users', { adminTab: 'users', usrs })
})
})
router.get('/users/:id', (req, res) => {
if (!res.locals.rights.manage) {
return res.render('error-forbidden')
}
if (!validator.isMongoId(req.params.id)) {
return res.render('error-forbidden')
}
db.User.findById(req.params.id)
.select('-password -providerId')
.exec().then((usr) => {
let usrOpts = {
canChangeEmail: (usr.email !== 'guest' && usr.provider === 'local' && usr.email !== req.app.locals.appconfig.admin),
canChangeName: (usr.email !== 'guest'),
canChangePassword: (usr.email !== 'guest' && usr.provider === 'local'),
canChangeRole: (usr.email !== 'guest' && !(usr.provider === 'local' && usr.email === req.app.locals.appconfig.admin)),
canBeDeleted: (usr.email !== 'guest' && !(usr.provider === 'local' && usr.email === req.app.locals.appconfig.admin))
}
res.render('pages/admin/users-edit', { adminTab: 'users', usr, usrOpts })
})
})
/**
* Create / Authorize a new user
*/
router.post('/users/create', (req, res) => {
if (!res.locals.rights.manage) {
return res.status(401).json({ msg: 'Unauthorized' })
}
let nUsr = {
email: _.toLower(_.trim(req.body.email)),
provider: _.trim(req.body.provider),
password: req.body.password,
name: _.trim(req.body.name)
}
if (!validator.isEmail(nUsr.email)) {
return res.status(400).json({ msg: 'Invalid email address' })
} else if (!validator.isIn(nUsr.provider, ['local', 'google', 'windowslive', 'facebook', 'github', 'slack'])) {
return res.status(400).json({ msg: 'Invalid provider' })
} else if (nUsr.provider === 'local' && !validator.isLength(nUsr.password, { min: 6 })) {
return res.status(400).json({ msg: 'Password too short or missing' })
} else if (nUsr.provider === 'local' && !validator.isLength(nUsr.name, { min: 2 })) {
return res.status(400).json({ msg: 'Name is missing' })
}
db.User.findOne({ email: nUsr.email, provider: nUsr.provider }).then(exUsr => {
if (exUsr) {
return res.status(400).json({ msg: 'User already exists!' }) || true
}
let pwdGen = (nUsr.provider === 'local') ? db.User.hashPassword(nUsr.password) : Promise.resolve(true)
return pwdGen.then(nPwd => {
if (nUsr.provider !== 'local') {
nUsr.password = ''
nUsr.name = '-- pending --'
} else {
nUsr.password = nPwd
}
nUsr.rights = [{
role: 'read',
path: '/',
exact: false,
deny: false
}]
return db.User.create(nUsr).then(() => {
return res.json({ ok: true })
})
}).catch(err => {
winston.warn(err)
return res.status(500).json({ msg: err })
})
}).catch(err => {
winston.warn(err)
return res.status(500).json({ msg: err })
})
})
router.post('/users/:id', (req, res) => {
if (!res.locals.rights.manage) {
return res.status(401).json({ msg: 'Unauthorized' })
}
if (!validator.isMongoId(req.params.id)) {
return res.status(400).json({ msg: 'Invalid User ID' })
}
return db.User.findById(req.params.id).then((usr) => {
usr.name = _.trim(req.body.name)
usr.rights = JSON.parse(req.body.rights)
if (usr.provider === 'local' && req.body.password !== '********') {
let nPwd = _.trim(req.body.password)
if (nPwd.length < 6) {
return Promise.reject(new Error('New Password too short!'))
} else {
return db.User.hashPassword(nPwd).then((pwd) => {
usr.password = pwd
return usr.save()
})
}
} else {
return usr.save()
}
}).then(() => {
return res.json({ msg: 'OK' })
}).catch((err) => {
res.status(400).json({ msg: err.message })
})
})
/**
* Delete / Deauthorize a user
*/
router.delete('/users/:id', (req, res) => {
if (!res.locals.rights.manage) {
return res.status(401).json({ msg: 'Unauthorized' })
}
if (!validator.isMongoId(req.params.id)) {
return res.status(400).json({ msg: 'Invalid User ID' })
}
return db.User.findByIdAndRemove(req.params.id).then(() => {
return res.json({ msg: 'OK' })
}).catch((err) => {
res.status(500).json({ msg: err.message })
})
})
router.get('/settings', (req, res) => {
if (!res.locals.rights.manage) {
return res.render('error-forbidden')
}
fs.readJsonAsync(path.join(ROOTPATH, 'package.json')).then(packageObj => {
axios.get('https://api.github.com/repos/Requarks/wiki/releases/latest').then(resp => {
let sysversion = {
current: 'v' + packageObj.version,
latest: resp.data.tag_name,
latestPublishedAt: resp.data.published_at
}
res.render('pages/admin/settings', { adminTab: 'settings', sysversion })
}).catch(err => {
winston.warn(err)
res.render('pages/admin/settings', { adminTab: 'settings', sysversion: { current: 'v' + packageObj.version } })
})
})
})
router.post('/settings/install', (req, res) => {
if (!res.locals.rights.manage) {
return res.render('error-forbidden')
}
// let sysLib = require(path.join(ROOTPATH, 'libs/system.js'))
// sysLib.install('v1.0-beta.7')
res.status(400).send('Sorry, Upgrade/Re-Install via the web UI is not yet ready. You must use the npm upgrade method in the meantime.').end()
})
module.exports = router
+114
View File
@@ -0,0 +1,114 @@
'use strict'
const Promise = require('bluebird')
const express = require('express')
const router = express.Router()
const passport = require('passport')
const ExpressBrute = require('express-brute')
const ExpressBruteMongooseStore = require('express-brute-mongoose')
const moment = require('moment')
/**
* Setup Express-Brute
*/
const EBstore = new ExpressBruteMongooseStore(db.Bruteforce)
const bruteforce = new ExpressBrute(EBstore, {
freeRetries: 5,
minWait: 60 * 1000,
maxWait: 5 * 60 * 1000,
refreshTimeoutOnRequest: false,
failCallback (req, res, next, nextValidRequestDate) {
req.flash('alert', {
class: 'error',
title: 'Too many attempts!',
message: "You've made too many failed attempts in a short period of time, please try again " + moment(nextValidRequestDate).fromNow() + '.',
iconClass: 'fa-times'
})
res.redirect('/login')
}
})
/**
* Login form
*/
router.get('/login', function (req, res, next) {
res.render('auth/login', {
usr: res.locals.usr
})
})
router.post('/login', bruteforce.prevent, function (req, res, next) {
new Promise((resolve, reject) => {
// [1] LOCAL AUTHENTICATION
passport.authenticate('local', function (err, user, info) {
if (err) { return reject(err) }
if (!user) { return reject(new Error('INVALID_LOGIN')) }
resolve(user)
})(req, res, next)
}).catch({ message: 'INVALID_LOGIN' }, err => {
if (appconfig.auth.ldap && appconfig.auth.ldap.enabled) {
// [2] LDAP AUTHENTICATION
return new Promise((resolve, reject) => {
passport.authenticate('ldapauth', function (err, user, info) {
if (err) { return reject(err) }
if (info && info.message) { return reject(new Error(info.message)) }
if (!user) { return reject(new Error('INVALID_LOGIN')) }
resolve(user)
})(req, res, next)
})
} else {
throw err
}
}).then((user) => {
// LOGIN SUCCESS
return req.logIn(user, function (err) {
if (err) { return next(err) }
req.brute.reset(function () {
return res.redirect('/')
})
}) || true
}).catch(err => {
// LOGIN FAIL
if (err.message === 'INVALID_LOGIN') {
req.flash('alert', {
title: 'Invalid login',
message: 'The email or password is invalid.'
})
return res.redirect('/login')
} else {
req.flash('alert', {
title: 'Login error',
message: err.message
})
return res.redirect('/login')
}
})
})
/**
* Social Login
*/
router.get('/login/ms', passport.authenticate('windowslive', { scope: ['wl.signin', 'wl.basic', 'wl.emails'] }))
router.get('/login/google', passport.authenticate('google', { scope: ['profile', 'email'] }))
router.get('/login/facebook', passport.authenticate('facebook', { scope: ['public_profile', 'email'] }))
router.get('/login/github', passport.authenticate('github', { scope: ['user:email'] }))
router.get('/login/slack', passport.authenticate('slack', { scope: ['identity.basic', 'identity.email'] }))
router.get('/login/azure', passport.authenticate('azure_ad_oauth2'))
router.get('/login/ms/callback', passport.authenticate('windowslive', { failureRedirect: '/login', successRedirect: '/' }))
router.get('/login/google/callback', passport.authenticate('google', { failureRedirect: '/login', successRedirect: '/' }))
router.get('/login/facebook/callback', passport.authenticate('facebook', { failureRedirect: '/login', successRedirect: '/' }))
router.get('/login/github/callback', passport.authenticate('github', { failureRedirect: '/login', successRedirect: '/' }))
router.get('/login/slack/callback', passport.authenticate('slack', { failureRedirect: '/login', successRedirect: '/' }))
router.get('/login/azure/callback', passport.authenticate('azure_ad_oauth2', { failureRedirect: '/login', successRedirect: '/' }))
/**
* Logout
*/
router.get('/logout', function (req, res) {
req.logout()
res.redirect('/')
})
module.exports = router
+247
View File
@@ -0,0 +1,247 @@
'use strict'
const express = require('express')
const router = express.Router()
const _ = require('lodash')
// ==========================================
// EDIT MODE
// ==========================================
/**
* Edit document in Markdown
*/
router.get('/edit/*', (req, res, next) => {
if (!res.locals.rights.write) {
return res.render('error-forbidden')
}
let safePath = entries.parsePath(_.replace(req.path, '/edit', ''))
entries.fetchOriginal(safePath, {
parseMarkdown: false,
parseMeta: true,
parseTree: false,
includeMarkdown: true,
includeParentInfo: false,
cache: false
}).then((pageData) => {
if (pageData) {
res.render('pages/edit', { pageData })
} else {
throw new Error('Invalid page path.')
}
return true
}).catch((err) => {
res.render('error', {
message: err.message,
error: {}
})
})
})
router.put('/edit/*', (req, res, next) => {
if (!res.locals.rights.write) {
return res.json({
ok: false,
error: 'Forbidden'
})
}
let safePath = entries.parsePath(_.replace(req.path, '/edit', ''))
entries.update(safePath, req.body.markdown, req.user).then(() => {
return res.json({
ok: true
}) || true
}).catch((err) => {
res.json({
ok: false,
error: err.message
})
})
})
// ==========================================
// CREATE MODE
// ==========================================
router.get('/create/*', (req, res, next) => {
if (!res.locals.rights.write) {
return res.render('error-forbidden')
}
if (_.some(['create', 'edit', 'account', 'source', 'history', 'mk', 'all'], (e) => { return _.startsWith(req.path, '/create/' + e) })) {
return res.render('error', {
message: 'You cannot create a document with this name as it is reserved by the system.',
error: {}
})
}
let safePath = entries.parsePath(_.replace(req.path, '/create', ''))
entries.exists(safePath).then((docExists) => {
if (!docExists) {
return entries.getStarter(safePath).then((contents) => {
let pageData = {
markdown: contents,
meta: {
title: _.startCase(safePath),
path: safePath
}
}
res.render('pages/create', { pageData })
return true
}).catch((err) => {
winston.warn(err)
throw new Error('Could not load starter content!')
})
} else {
throw new Error('This entry already exists!')
}
}).catch((err) => {
res.render('error', {
message: err.message,
error: {}
})
})
})
router.put('/create/*', (req, res, next) => {
if (!res.locals.rights.write) {
return res.json({
ok: false,
error: 'Forbidden'
})
}
let safePath = entries.parsePath(_.replace(req.path, '/create', ''))
entries.create(safePath, req.body.markdown, req.user).then(() => {
return res.json({
ok: true
}) || true
}).catch((err) => {
return res.json({
ok: false,
error: err.message
})
})
})
// ==========================================
// LIST ALL PAGES
// ==========================================
/**
* View tree view of all pages
*/
router.use((req, res, next) => {
if (_.endsWith(req.url, '/all')) {
res.render('pages/all')
} else {
next()
}
})
// ==========================================
// VIEW MODE
// ==========================================
/**
* View source of a document
*/
router.get('/source/*', (req, res, next) => {
let safePath = entries.parsePath(_.replace(req.path, '/source', ''))
entries.fetchOriginal(safePath, {
parseMarkdown: false,
parseMeta: true,
parseTree: false,
includeMarkdown: true,
includeParentInfo: false,
cache: false
}).then((pageData) => {
if (pageData) {
res.render('pages/source', { pageData })
} else {
throw new Error('Invalid page path.')
}
return true
}).catch((err) => {
res.render('error', {
message: err.message,
error: {}
})
})
})
/**
* View document
*/
router.get('/*', (req, res, next) => {
let safePath = entries.parsePath(req.path)
entries.fetch(safePath).then((pageData) => {
if (pageData) {
res.render('pages/view', { pageData })
} else {
res.render('error-notexist', {
newpath: safePath
})
}
return true
}).error((err) => {
if (safePath === 'home') {
res.render('pages/welcome')
} else {
res.render('error-notexist', {
message: err.message,
newpath: safePath
})
}
return true
}).catch((err) => {
res.render('error', {
message: err.message,
error: {}
})
})
})
/**
* Move document
*/
router.put('/*', (req, res, next) => {
if (!res.locals.rights.write) {
return res.json({
ok: false,
error: 'Forbidden'
})
}
let safePath = entries.parsePath(req.path)
if (_.isEmpty(req.body.move)) {
return res.json({
ok: false,
error: 'Invalid document action call.'
})
}
let safeNewPath = entries.parsePath(req.body.move)
entries.move(safePath, safeNewPath, req.user).then(() => {
res.json({
ok: true
})
}).catch((err) => {
res.json({
ok: false,
error: err.message
})
})
})
module.exports = router
+160
View File
@@ -0,0 +1,160 @@
'use strict'
const express = require('express')
const router = express.Router()
const readChunk = require('read-chunk')
const fileType = require('file-type')
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const path = require('path')
const _ = require('lodash')
const validPathRe = new RegExp('^(([a-z0-9/-]|' + appdata.regex.cjk.source + ')+\\.[a-z0-9]+)$')
const validPathThumbsRe = new RegExp('^([a-z0-9]+\\.png)$')
// ==========================================
// SERVE UPLOADS FILES
// ==========================================
router.get('/t/*', (req, res, next) => {
let fileName = req.params[0]
if (!validPathThumbsRe.test(fileName)) {
return res.sendStatus(404).end()
}
// todo: Authentication-based access
res.sendFile(fileName, {
root: lcdata.getThumbsPath(),
dotfiles: 'deny'
}, (err) => {
if (err) {
res.status(err.status).end()
}
})
})
router.post('/img', lcdata.uploadImgHandler, (req, res, next) => {
let destFolder = _.chain(req.body.folder).trim().toLower().value()
upl.validateUploadsFolder(destFolder).then((destFolderPath) => {
if (!destFolderPath) {
res.json({ ok: false, msg: 'Invalid Folder' })
return true
}
Promise.map(req.files, (f) => {
let destFilename = ''
let destFilePath = ''
return lcdata.validateUploadsFilename(f.originalname, destFolder, true).then((fname) => {
destFilename = fname
destFilePath = path.resolve(destFolderPath, destFilename)
return readChunk(f.path, 0, 262)
}).then((buf) => {
// -> Check MIME type by magic number
let mimeInfo = fileType(buf)
if (!_.includes(['image/png', 'image/jpeg', 'image/gif', 'image/webp'], mimeInfo.mime)) {
return Promise.reject(new Error('Invalid file type.'))
}
return true
}).then(() => {
// -> Move file to final destination
return fs.moveAsync(f.path, destFilePath, { clobber: false })
}).then(() => {
return {
ok: true,
filename: destFilename,
filesize: f.size
}
}).reflect()
}, {concurrency: 3}).then((results) => {
let uplResults = _.map(results, (r) => {
if (r.isFulfilled()) {
return r.value()
} else {
return {
ok: false,
msg: r.reason().message
}
}
})
res.json({ ok: true, results: uplResults })
return true
}).catch((err) => {
res.json({ ok: false, msg: err.message })
return true
})
})
})
router.post('/file', lcdata.uploadFileHandler, (req, res, next) => {
let destFolder = _.chain(req.body.folder).trim().toLower().value()
upl.validateUploadsFolder(destFolder).then((destFolderPath) => {
if (!destFolderPath) {
res.json({ ok: false, msg: 'Invalid Folder' })
return true
}
Promise.map(req.files, (f) => {
let destFilename = ''
let destFilePath = ''
return lcdata.validateUploadsFilename(f.originalname, destFolder, false).then((fname) => {
destFilename = fname
destFilePath = path.resolve(destFolderPath, destFilename)
// -> Move file to final destination
return fs.moveAsync(f.path, destFilePath, { clobber: false })
}).then(() => {
return {
ok: true,
filename: destFilename,
filesize: f.size
}
}).reflect()
}, {concurrency: 3}).then((results) => {
let uplResults = _.map(results, (r) => {
if (r.isFulfilled()) {
return r.value()
} else {
return {
ok: false,
msg: r.reason().message
}
}
})
res.json({ ok: true, results: uplResults })
return true
}).catch((err) => {
res.json({ ok: false, msg: err.message })
return true
})
})
})
router.get('/*', (req, res, next) => {
let fileName = req.params[0]
if (!validPathRe.test(fileName)) {
return res.sendStatus(404).end()
}
// todo: Authentication-based access
res.sendFile(fileName, {
root: git.getRepoPath() + '/uploads/',
dotfiles: 'deny'
}, (err) => {
if (err) {
res.status(err.status).end()
}
})
})
module.exports = router
+110
View File
@@ -0,0 +1,110 @@
'use strict'
/* eslint-disable standard/no-callback-literal */
const _ = require('lodash')
module.exports = (socket) => {
// -----------------------------------------
// SEARCH
// -----------------------------------------
if (appconfig.public || socket.request.user.logged_in) {
socket.on('search', (data, cb) => {
cb = cb || _.noop
search.find(data.terms).then((results) => {
return cb(results) || true
})
})
}
// -----------------------------------------
// TREE VIEW (LIST ALL PAGES)
// -----------------------------------------
if (socket.request.user.logged_in) {
socket.on('treeFetch', (data, cb) => {
cb = cb || _.noop
entries.getFromTree(data.basePath).then((f) => {
return cb(f) || true
})
})
}
// -----------------------------------------
// UPLOADS
// -----------------------------------------
if (socket.request.user.logged_in) {
socket.on('uploadsGetFolders', (data, cb) => {
cb = cb || _.noop
upl.getUploadsFolders().then((f) => {
return cb(f) || true
})
})
socket.on('uploadsCreateFolder', (data, cb) => {
cb = cb || _.noop
upl.createUploadsFolder(data.foldername).then((f) => {
return cb(f) || true
})
})
socket.on('uploadsGetImages', (data, cb) => {
cb = cb || _.noop
upl.getUploadsFiles('image', data.folder).then((f) => {
return cb(f) || true
})
})
socket.on('uploadsGetFiles', (data, cb) => {
cb = cb || _.noop
upl.getUploadsFiles('binary', data.folder).then((f) => {
return cb(f) || true
})
})
socket.on('uploadsDeleteFile', (data, cb) => {
cb = cb || _.noop
upl.deleteUploadsFile(data.uid).then((f) => {
return cb(f) || true
})
})
socket.on('uploadsFetchFileFromURL', (data, cb) => {
cb = cb || _.noop
upl.downloadFromUrl(data.folder, data.fetchUrl).then((f) => {
return cb({ ok: true }) || true
}).catch((err) => {
return cb({
ok: false,
msg: err.message
}) || true
})
})
socket.on('uploadsRenameFile', (data, cb) => {
cb = cb || _.noop
upl.moveUploadsFile(data.uid, data.folder, data.filename).then((f) => {
return cb({ ok: true }) || true
}).catch((err) => {
return cb({
ok: false,
msg: err.message
}) || true
})
})
socket.on('uploadsMoveFile', (data, cb) => {
cb = cb || _.noop
upl.moveUploadsFile(data.uid, data.folder).then((f) => {
return cb({ ok: true }) || true
}).catch((err) => {
return cb({
ok: false,
msg: err.message
}) || true
})
})
}
}
+260
View File
@@ -0,0 +1,260 @@
'use strict'
// ===========================================
// Wiki.js
// 1.0.0
// Licensed under AGPLv3
// ===========================================
const path = require('path')
const ROOTPATH = process.cwd()
const SERVERPATH = path.join(ROOTPATH, 'server')
global.PROCNAME = 'SERVER'
global.ROOTPATH = ROOTPATH
global.SERVERPATH = SERVERPATH
global.IS_DEBUG = process.env.NODE_ENV === 'development'
process.env.VIPS_WARNING = false
let appconf = require('./libs/config')()
global.appconfig = appconf.config
global.appdata = appconf.data
// ----------------------------------------
// Load Winston
// ----------------------------------------
global.winston = require('./libs/logger')(IS_DEBUG)
winston.info('[SERVER] Wiki.js is initializing...')
// ----------------------------------------
// Load global modules
// ----------------------------------------
global.lcdata = require('./libs/local').init()
global.db = require('./libs/db').init()
global.entries = require('./libs/entries').init()
global.git = require('./libs/git').init(false)
global.lang = require('i18next')
global.mark = require('./libs/markdown')
global.search = require('./libs/search').init()
global.upl = require('./libs/uploads').init()
// ----------------------------------------
// Load modules
// ----------------------------------------
const autoload = require('auto-load')
const bodyParser = require('body-parser')
const compression = require('compression')
const cookieParser = require('cookie-parser')
const express = require('express')
const favicon = require('serve-favicon')
const flash = require('connect-flash')
const fork = require('child_process').fork
const http = require('http')
const i18nextBackend = require('i18next-node-fs-backend')
const i18nextMw = require('i18next-express-middleware')
const passport = require('passport')
const passportSocketIo = require('passport.socketio')
const session = require('express-session')
const SessionMongoStore = require('connect-mongo')(session)
const socketio = require('socket.io')
var mw = autoload(path.join(SERVERPATH, '/middlewares'))
var ctrl = autoload(path.join(SERVERPATH, '/controllers'))
// ----------------------------------------
// Define Express App
// ----------------------------------------
global.app = express()
app.use(compression())
// ----------------------------------------
// Security
// ----------------------------------------
app.use(mw.security)
// ----------------------------------------
// Public Assets
// ----------------------------------------
app.use(favicon(path.join(ROOTPATH, 'assets', 'favicon.ico')))
app.use(express.static(path.join(ROOTPATH, 'assets')))
// ----------------------------------------
// Passport Authentication
// ----------------------------------------
require('./libs/auth')(passport)
global.rights = require('./libs/rights')
rights.init()
var sessionStore = new SessionMongoStore({
mongooseConnection: db.connection,
touchAfter: 15
})
app.use(cookieParser())
app.use(session({
name: 'requarkswiki.sid',
store: sessionStore,
secret: appconfig.sessionSecret,
resave: false,
saveUninitialized: false
}))
app.use(flash())
app.use(passport.initialize())
app.use(passport.session())
// ----------------------------------------
// SEO
// ----------------------------------------
app.use(mw.seo)
// ----------------------------------------
// Localization Engine
// ----------------------------------------
lang
.use(i18nextBackend)
.use(i18nextMw.LanguageDetector)
.init({
load: 'languageOnly',
ns: ['common', 'auth'],
defaultNS: 'common',
saveMissing: false,
supportedLngs: ['en', 'fr'],
preload: ['en', 'fr'],
fallbackLng: 'en',
backend: {
loadPath: './locales/{{lng}}/{{ns}}.json'
}
})
// ----------------------------------------
// View Engine Setup
// ----------------------------------------
app.use(i18nextMw.handle(lang))
app.set('views', path.join(SERVERPATH, 'views'))
app.set('view engine', 'pug')
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: false }))
// ----------------------------------------
// View accessible data
// ----------------------------------------
app.locals._ = require('lodash')
app.locals.moment = require('moment')
app.locals.appconfig = appconfig
app.use(mw.flash)
// ----------------------------------------
// Controllers
// ----------------------------------------
app.use('/', ctrl.auth)
app.use('/uploads', mw.auth, ctrl.uploads)
app.use('/admin', mw.auth, ctrl.admin)
app.use('/', mw.auth, ctrl.pages)
// ----------------------------------------
// Error handling
// ----------------------------------------
app.use(function (req, res, next) {
var err = new Error('Not Found')
err.status = 404
next(err)
})
app.use(function (err, req, res, next) {
res.status(err.status || 500)
res.render('error', {
message: err.message,
error: IS_DEBUG ? err : {}
})
})
// ----------------------------------------
// Start HTTP server
// ----------------------------------------
winston.info('[SERVER] Starting HTTP/WS server on port ' + appconfig.port + '...')
app.set('port', appconfig.port)
var server = http.createServer(app)
var io = socketio(server)
server.listen(appconfig.port)
server.on('error', (error) => {
if (error.syscall !== 'listen') {
throw error
}
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error('Listening on port ' + appconfig.port + ' requires elevated privileges!')
return process.exit(1)
case 'EADDRINUSE':
console.error('Port ' + appconfig.port + ' is already in use!')
return process.exit(1)
default:
throw error
}
})
server.on('listening', () => {
winston.info('[SERVER] HTTP/WS server started successfully! [RUNNING]')
})
// ----------------------------------------
// WebSocket
// ----------------------------------------
io.use(passportSocketIo.authorize({
key: 'requarkswiki.sid',
store: sessionStore,
secret: appconfig.sessionSecret,
passport,
cookieParser,
success: (data, accept) => {
accept()
},
fail: (data, message, error, accept) => {
return accept(new Error(message))
}
}))
io.on('connection', ctrl.ws)
// ----------------------------------------
// Start child processes
// ----------------------------------------
let bgAgent = fork(path.join(SERVERPATH, 'agent.js'))
bgAgent.on('message', m => {
if (!m.action) {
return
}
switch (m.action) {
case 'searchAdd':
search.add(m.content)
break
}
})
process.on('exit', (code) => {
bgAgent.disconnect()
})
+103
View File
@@ -0,0 +1,103 @@
'use strict'
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const pm2 = Promise.promisifyAll(require('pm2'))
const ora = require('ora')
const path = require('path')
const ROOTPATH = process.cwd()
module.exports = {
/**
* Detect the most appropriate start mode
*/
startDetect: function () {
if (!process.env.IS_HEROKU) {
return this.startInHerokuMode()
} else {
return this.startInBackgroundMode()
}
},
/**
* Start in background mode
*/
startInBackgroundMode: function () {
let spinner = ora('Initializing...').start()
return fs.emptyDirAsync(path.join(ROOTPATH, './logs')).then(() => {
return pm2.connectAsync().then(() => {
return pm2.startAsync({
name: 'wiki',
script: 'server',
cwd: ROOTPATH,
output: path.join(ROOTPATH, './logs/wiki-output.log'),
error: path.join(ROOTPATH, './logs/wiki-error.log'),
minUptime: 5000,
maxRestarts: 5
}).then(() => {
spinner.succeed('Wiki.js has started successfully.')
}).finally(() => {
pm2.disconnect()
})
})
}).catch(err => {
spinner.fail(err)
process.exit(1)
})
},
/**
* Start in Heroku mode
*/
startInHerokuMode: function () {
let self = this
console.info('Initializing Wiki.js for Heroku...')
let herokuStatePath = path.join(__dirname, './app/heroku.json')
return fs.accessAsync(herokuStatePath).then(() => {
require('./server.js')
}).catch(err => {
if (err.code === 'ENOENT') {
console.info('Wiki.js is not configured yet. Launching configuration wizard...')
self.configure(process.env.PORT)
} else {
console.error(err)
process.exit(1)
}
})
},
/**
* Stop Wiki.js process(es)
*/
stop () {
let spinner = ora('Shutting down Wiki.js...').start()
return pm2.connectAsync().then(() => {
return pm2.stopAsync('wiki').then(() => {
spinner.succeed('Wiki.js has stopped successfully.')
}).finally(() => {
pm2.disconnect()
})
}).catch(err => {
spinner.fail(err)
process.exit(1)
})
},
/**
* Restart Wiki.js process(es)
*/
restart: function () {
let self = this
return self.stop().delay(1000).then(() => {
self.startDetect()
})
},
/**
* Start the web-based configuration wizard
*
* @param {Number} port Port to bind the HTTP server on
*/
configure (port) {
port = port || 3000
let spinner = ora('Initializing interactive setup...').start()
require('./configure')(port, spinner)
}
}
+245
View File
@@ -0,0 +1,245 @@
'use strict'
/* global appconfig, appdata, db, winston */
const fs = require('fs')
module.exports = function (passport) {
// Serialization user methods
passport.serializeUser(function (user, done) {
done(null, user._id)
})
passport.deserializeUser(function (id, done) {
db.User.findById(id).then((user) => {
if (user) {
done(null, user)
} else {
done(new Error('User not found.'), null)
}
return true
}).catch((err) => {
done(err, null)
})
})
// Local Account
if (!appdata.capabilities.manyAuthProviders || (appconfig.auth.local && appconfig.auth.local.enabled)) {
const LocalStrategy = require('passport-local').Strategy
passport.use('local',
new LocalStrategy({
usernameField: 'email',
passwordField: 'password'
},
(uEmail, uPassword, done) => {
db.User.findOne({ email: uEmail, provider: 'local' }).then((user) => {
if (user) {
return user.validatePassword(uPassword).then(() => {
return done(null, user) || true
}).catch((err) => {
return done(err, null)
})
} else {
return done(new Error('INVALID_LOGIN'), null)
}
}).catch((err) => {
done(err, null)
})
}
))
}
// Google ID
if (appdata.capabilities.manyAuthProviders && appconfig.auth.google && appconfig.auth.google.enabled) {
const GoogleStrategy = require('passport-google-oauth20').Strategy
passport.use('google',
new GoogleStrategy({
clientID: appconfig.auth.google.clientId,
clientSecret: appconfig.auth.google.clientSecret,
callbackURL: appconfig.host + '/login/google/callback'
},
(accessToken, refreshToken, profile, cb) => {
db.User.processProfile(profile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// Microsoft Accounts
if (appdata.capabilities.manyAuthProviders && appconfig.auth.microsoft && appconfig.auth.microsoft.enabled) {
const WindowsLiveStrategy = require('passport-windowslive').Strategy
passport.use('windowslive',
new WindowsLiveStrategy({
clientID: appconfig.auth.microsoft.clientId,
clientSecret: appconfig.auth.microsoft.clientSecret,
callbackURL: appconfig.host + '/login/ms/callback'
},
function (accessToken, refreshToken, profile, cb) {
db.User.processProfile(profile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// Facebook
if (appdata.capabilities.manyAuthProviders && appconfig.auth.facebook && appconfig.auth.facebook.enabled) {
const FacebookStrategy = require('passport-facebook').Strategy
passport.use('facebook',
new FacebookStrategy({
clientID: appconfig.auth.facebook.clientId,
clientSecret: appconfig.auth.facebook.clientSecret,
callbackURL: appconfig.host + '/login/facebook/callback',
profileFields: ['id', 'displayName', 'email']
},
function (accessToken, refreshToken, profile, cb) {
db.User.processProfile(profile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// GitHub
if (appdata.capabilities.manyAuthProviders && appconfig.auth.github && appconfig.auth.github.enabled) {
const GitHubStrategy = require('passport-github2').Strategy
passport.use('github',
new GitHubStrategy({
clientID: appconfig.auth.github.clientId,
clientSecret: appconfig.auth.github.clientSecret,
callbackURL: appconfig.host + '/login/github/callback',
scope: [ 'user:email' ]
},
(accessToken, refreshToken, profile, cb) => {
db.User.processProfile(profile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// Slack
if (appdata.capabilities.manyAuthProviders && appconfig.auth.slack && appconfig.auth.slack.enabled) {
const SlackStrategy = require('passport-slack').Strategy
passport.use('slack',
new SlackStrategy({
clientID: appconfig.auth.slack.clientId,
clientSecret: appconfig.auth.slack.clientSecret,
callbackURL: appconfig.host + '/login/slack/callback'
},
(accessToken, refreshToken, profile, cb) => {
db.User.processProfile(profile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// LDAP
if (appdata.capabilities.manyAuthProviders && appconfig.auth.ldap && appconfig.auth.ldap.enabled) {
const LdapStrategy = require('passport-ldapauth').Strategy
passport.use('ldapauth',
new LdapStrategy({
server: {
url: appconfig.auth.ldap.url,
bindDn: appconfig.auth.ldap.bindDn,
bindCredentials: appconfig.auth.ldap.bindCredentials,
searchBase: appconfig.auth.ldap.searchBase,
searchFilter: appconfig.auth.ldap.searchFilter,
searchAttributes: ['displayName', 'name', 'cn', 'mail'],
tlsOptions: (appconfig.auth.ldap.tlsEnabled) ? {
ca: [
fs.readFileSync(appconfig.auth.ldap.tlsCertPath)
]
} : {}
},
usernameField: 'email',
passReqToCallback: false
},
(profile, cb) => {
profile.provider = 'ldap'
profile.id = profile.dn
db.User.processProfile(profile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// AZURE AD
if (appdata.capabilities.manyAuthProviders && appconfig.auth.azure && appconfig.auth.azure.enabled) {
const AzureAdOAuth2Strategy = require('passport-azure-ad-oauth2').Strategy
const jwt = require('jsonwebtoken')
passport.use('azure_ad_oauth2',
new AzureAdOAuth2Strategy({
clientID: appconfig.auth.azure.clientId,
clientSecret: appconfig.auth.azure.clientSecret,
callbackURL: appconfig.host + '/login/azure/callback',
resource: appconfig.auth.azure.resource,
tenant: appconfig.auth.azure.tenant
},
(accessToken, refreshToken, params, profile, cb) => {
let waadProfile = jwt.decode(params.id_token)
waadProfile.id = waadProfile.oid
waadProfile.provider = 'azure'
db.User.processProfile(waadProfile).then((user) => {
return cb(null, user) || true
}).catch((err) => {
return cb(err, null) || true
})
}
))
}
// Create users for first-time
db.onReady.then(() => {
db.User.findOne({ provider: 'local', email: 'guest' }).then((c) => {
if (c < 1) {
// Create guest account
return db.User.create({
provider: 'local',
email: 'guest',
name: 'Guest',
password: '',
rights: [{
role: 'read',
path: '/',
exact: false,
deny: !appconfig.public
}]
}).then(() => {
winston.info('[AUTH] Guest account created successfully!')
}).catch((err) => {
winston.error('[AUTH] An error occured while creating guest account:')
winston.error(err)
})
}
})
return true
})
}
+69
View File
@@ -0,0 +1,69 @@
'use strict'
const fs = require('fs')
const yaml = require('js-yaml')
const _ = require('lodash')
const path = require('path')
/**
* Load Application Configuration
*
* @param {Object} confPaths Path to the configuration files
* @return {Object} Application Configuration
*/
module.exports = (confPaths) => {
confPaths = _.defaults(confPaths, {
config: path.join(ROOTPATH, 'config.yml'),
data: path.join(SERVERPATH, 'app/data.yml'),
dataRegex: path.join(SERVERPATH, 'app/regex.js')
})
let appconfig = {}
let appdata = {}
try {
appconfig = yaml.safeLoad(fs.readFileSync(confPaths.config, 'utf8'))
appdata = yaml.safeLoad(fs.readFileSync(confPaths.data, 'utf8'))
appdata.regex = require(confPaths.dataRegex)
} catch (ex) {
console.error(ex)
process.exit(1)
}
// Merge with defaults
appconfig = _.defaultsDeep(appconfig, appdata.defaults.config)
// Using ENV variables?
if (appconfig.port < 1) {
appconfig.port = process.env.PORT || 80
}
if (_.startsWith(appconfig.db, '$')) {
appconfig.db = process.env[appconfig.db.slice(1)]
}
// List authentication strategies
if (appdata.capabilities.manyAuthProviders) {
appconfig.authStrategies = {
list: _.filter(appconfig.auth, ['enabled', true]),
socialEnabled: (_.chain(appconfig.auth).omit('local').reject({ enabled: false }).value().length > 0)
}
if (appconfig.authStrategies.list.length < 1) {
console.error(new Error('You must enable at least 1 authentication strategy!'))
process.exit(1)
}
} else {
appconfig.authStrategies = {
list: { local: { enabled: true } },
socialEnabled: false
}
}
return {
config: appconfig,
data: appdata
}
}
+64
View File
@@ -0,0 +1,64 @@
'use strict'
/* global ROOTPATH, appconfig, winston */
const modb = require('mongoose')
const fs = require('fs')
const path = require('path')
const _ = require('lodash')
/**
* MongoDB module
*
* @return {Object} MongoDB wrapper instance
*/
module.exports = {
/**
* Initialize DB
*
* @return {Object} DB instance
*/
init () {
let self = this
global.Mongoose = modb
let dbModelsPath = path.join(SERVERPATH, 'models')
modb.Promise = require('bluebird')
// Event handlers
modb.connection.on('error', err => {
winston.error('Failed to connect to MongoDB instance.')
return err
})
modb.connection.once('open', function () {
winston.log('Connected to MongoDB instance.')
})
// Store connection handle
self.connection = modb.connection
self.ObjectId = modb.Types.ObjectId
// Load DB Models
fs
.readdirSync(dbModelsPath)
.filter(function (file) {
return (file.indexOf('.') !== 0)
})
.forEach(function (file) {
let modelName = _.upperFirst(_.camelCase(_.split(file, '.')[0]))
self[modelName] = require(path.join(dbModelsPath, file))
})
// Connect
self.onReady = modb.connect(appconfig.db)
return self
}
}
+454
View File
@@ -0,0 +1,454 @@
'use strict'
const Promise = require('bluebird')
const path = require('path')
const fs = Promise.promisifyAll(require('fs-extra'))
const _ = require('lodash')
const crypto = require('crypto')
const qs = require('querystring')
/**
* Entries Model
*/
module.exports = {
_repoPath: 'repo',
_cachePath: 'data/cache',
/**
* Initialize Entries model
*
* @return {Object} Entries model instance
*/
init () {
let self = this
self._repoPath = path.resolve(ROOTPATH, appconfig.paths.repo)
self._cachePath = path.resolve(ROOTPATH, appconfig.paths.data, 'cache')
return self
},
/**
* Check if a document already exists
*
* @param {String} entryPath The entry path
* @return {Promise<Boolean>} True if exists, false otherwise
*/
exists (entryPath) {
let self = this
return self.fetchOriginal(entryPath, {
parseMarkdown: false,
parseMeta: false,
parseTree: false,
includeMarkdown: false,
includeParentInfo: false,
cache: false
}).then(() => {
return true
}).catch((err) => { // eslint-disable-line handle-callback-err
return false
})
},
/**
* Fetch a document from cache, otherwise the original
*
* @param {String} entryPath The entry path
* @return {Promise<Object>} Page Data
*/
fetch (entryPath) {
let self = this
let cpath = self.getCachePath(entryPath)
return fs.statAsync(cpath).then((st) => {
return st.isFile()
}).catch((err) => { // eslint-disable-line handle-callback-err
return false
}).then((isCache) => {
if (isCache) {
// Load from cache
return fs.readFileAsync(cpath).then((contents) => {
return JSON.parse(contents)
}).catch((err) => { // eslint-disable-line handle-callback-err
winston.error('Corrupted cache file. Deleting it...')
fs.unlinkSync(cpath)
return false
})
} else {
// Load original
return self.fetchOriginal(entryPath)
}
})
},
/**
* Fetches the original document entry
*
* @param {String} entryPath The entry path
* @param {Object} options The options
* @return {Promise<Object>} Page data
*/
fetchOriginal (entryPath, options) {
let self = this
let fpath = self.getFullPath(entryPath)
let cpath = self.getCachePath(entryPath)
options = _.defaults(options, {
parseMarkdown: true,
parseMeta: true,
parseTree: true,
includeMarkdown: false,
includeParentInfo: true,
cache: true
})
return fs.statAsync(fpath).then((st) => {
if (st.isFile()) {
return fs.readFileAsync(fpath, 'utf8').then((contents) => {
// Parse contents
let pageData = {
markdown: (options.includeMarkdown) ? contents : '',
html: (options.parseMarkdown) ? mark.parseContent(contents) : '',
meta: (options.parseMeta) ? mark.parseMeta(contents) : {},
tree: (options.parseTree) ? mark.parseTree(contents) : []
}
if (!pageData.meta.title) {
pageData.meta.title = _.startCase(entryPath)
}
pageData.meta.path = entryPath
// Get parent
let parentPromise = (options.includeParentInfo) ? self.getParentInfo(entryPath).then((parentData) => {
return (pageData.parent = parentData)
}).catch((err) => { // eslint-disable-line handle-callback-err
return (pageData.parent = false)
}) : Promise.resolve(true)
return parentPromise.then(() => {
// Cache to disk
if (options.cache) {
let cacheData = JSON.stringify(_.pick(pageData, ['html', 'meta', 'tree', 'parent']), false, false, false)
return fs.writeFileAsync(cpath, cacheData).catch((err) => {
winston.error('Unable to write to cache! Performance may be affected.')
winston.error(err)
return true
})
} else {
return true
}
}).return(pageData)
})
} else {
return false
}
}).catch((err) => { // eslint-disable-line handle-callback-err
throw new Promise.OperationalError('Entry ' + entryPath + ' does not exist!')
})
},
/**
* Parse raw url path and make it safe
*
* @param {String} urlPath The url path
* @return {String} Safe entry path
*/
parsePath (urlPath) {
urlPath = qs.unescape(urlPath)
let wlist = new RegExp('(?!([^a-z0-9]|' + appdata.regex.cjk.source + '|[/-]))', 'g')
urlPath = _.toLower(urlPath).replace(wlist, '')
if (urlPath === '/') {
urlPath = 'home'
}
let urlParts = _.filter(_.split(urlPath, '/'), (p) => { return !_.isEmpty(p) })
return _.join(urlParts, '/')
},
/**
* Gets the parent information.
*
* @param {String} entryPath The entry path
* @return {Promise<Object|False>} The parent information.
*/
getParentInfo (entryPath) {
let self = this
if (_.includes(entryPath, '/')) {
let parentParts = _.initial(_.split(entryPath, '/'))
let parentPath = _.join(parentParts, '/')
let parentFile = _.last(parentParts)
let fpath = self.getFullPath(parentPath)
return fs.statAsync(fpath).then((st) => {
if (st.isFile()) {
return fs.readFileAsync(fpath, 'utf8').then((contents) => {
let pageMeta = mark.parseMeta(contents)
return {
path: parentPath,
title: (pageMeta.title) ? pageMeta.title : _.startCase(parentFile),
subtitle: (pageMeta.subtitle) ? pageMeta.subtitle : false
}
})
} else {
return Promise.reject(new Error('Parent entry is not a valid file.'))
}
})
} else {
return Promise.reject(new Error('Parent entry is root.'))
}
},
/**
* Gets the full original path of a document.
*
* @param {String} entryPath The entry path
* @return {String} The full path.
*/
getFullPath (entryPath) {
return path.join(this._repoPath, entryPath + '.md')
},
/**
* Gets the full cache path of a document.
*
* @param {String} entryPath The entry path
* @return {String} The full cache path.
*/
getCachePath (entryPath) {
return path.join(this._cachePath, crypto.createHash('md5').update(entryPath).digest('hex') + '.json')
},
/**
* Gets the entry path from full path.
*
* @param {String} fullPath The full path
* @return {String} The entry path
*/
getEntryPathFromFullPath (fullPath) {
let absRepoPath = path.resolve(ROOTPATH, this._repoPath)
return _.chain(fullPath).replace(absRepoPath, '').replace('.md', '').replace(new RegExp('\\\\', 'g'), '/').value()
},
/**
* Update an existing document
*
* @param {String} entryPath The entry path
* @param {String} contents The markdown-formatted contents
* @param {Object} author The author user object
* @return {Promise<Boolean>} True on success, false on failure
*/
update (entryPath, contents, author) {
let self = this
let fpath = self.getFullPath(entryPath)
return fs.statAsync(fpath).then((st) => {
if (st.isFile()) {
return self.makePersistent(entryPath, contents, author).then(() => {
return self.updateCache(entryPath).then(entry => {
return search.add(entry)
})
})
} else {
return Promise.reject(new Error('Entry does not exist!'))
}
}).catch((err) => {
winston.error(err)
return Promise.reject(new Error('Failed to save document.'))
})
},
/**
* Update local cache
*
* @param {String} entryPath The entry path
* @return {Promise} Promise of the operation
*/
updateCache (entryPath) {
let self = this
return self.fetchOriginal(entryPath, {
parseMarkdown: true,
parseMeta: true,
parseTree: true,
includeMarkdown: true,
includeParentInfo: true,
cache: true
}).catch(err => {
winston.error(err)
return err
}).then((pageData) => {
return {
entryPath,
meta: pageData.meta,
parent: pageData.parent || {},
text: mark.removeMarkdown(pageData.markdown)
}
}).catch(err => {
winston.error(err)
return err
}).then((content) => {
let parentPath = _.chain(content.entryPath).split('/').initial().join('/').value()
return db.Entry.findOneAndUpdate({
_id: content.entryPath
}, {
_id: content.entryPath,
title: content.meta.title || content.entryPath,
subtitle: content.meta.subtitle || '',
parentTitle: content.parent.title || '',
parentPath: parentPath,
isDirectory: false,
isEntry: true
}, {
new: true,
upsert: true
})
}).then(result => {
return self.updateTreeInfo().then(() => {
return result
})
}).catch(err => {
winston.error(err)
return err
})
},
/**
* Update tree info for all directory and parent entries
*
* @returns {Promise<Boolean>} Promise of the operation
*/
updateTreeInfo () {
return db.Entry.distinct('parentPath', { parentPath: { $ne: '' } }).then(allPaths => {
if (allPaths.length > 0) {
return Promise.map(allPaths, pathItem => {
let parentPath = _.chain(pathItem).split('/').initial().join('/').value()
let guessedTitle = _.chain(pathItem).split('/').last().startCase().value()
return db.Entry.update({ _id: pathItem }, {
$set: { isDirectory: true },
$setOnInsert: { isEntry: false, title: guessedTitle, parentPath }
}, { upsert: true })
})
} else {
return true
}
})
},
/**
* Create a new document
*
* @param {String} entryPath The entry path
* @param {String} contents The markdown-formatted contents
* @param {Object} author The author user object
* @return {Promise<Boolean>} True on success, false on failure
*/
create (entryPath, contents, author) {
let self = this
return self.exists(entryPath).then((docExists) => {
if (!docExists) {
return self.makePersistent(entryPath, contents, author).then(() => {
return self.updateCache(entryPath).then(entry => {
return search.add(entry)
})
})
} else {
return Promise.reject(new Error('Entry already exists!'))
}
}).catch((err) => {
winston.error(err)
return Promise.reject(new Error('Something went wrong.'))
})
},
/**
* Makes a document persistent to disk and git repository
*
* @param {String} entryPath The entry path
* @param {String} contents The markdown-formatted contents
* @param {Object} author The author user object
* @return {Promise<Boolean>} True on success, false on failure
*/
makePersistent (entryPath, contents, author) {
let self = this
let fpath = self.getFullPath(entryPath)
return fs.outputFileAsync(fpath, contents).then(() => {
return git.commitDocument(entryPath, author)
})
},
/**
* Move a document
*
* @param {String} entryPath The current entry path
* @param {String} newEntryPath The new entry path
* @param {Object} author The author user object
* @return {Promise} Promise of the operation
*/
move (entryPath, newEntryPath, author) {
let self = this
if (_.isEmpty(entryPath) || entryPath === 'home') {
return Promise.reject(new Error('Invalid path!'))
}
return git.moveDocument(entryPath, newEntryPath).then(() => {
return git.commitDocument(newEntryPath, author).then(() => {
// Delete old cache version
let oldEntryCachePath = self.getCachePath(entryPath)
fs.unlinkAsync(oldEntryCachePath).catch((err) => { return true }) // eslint-disable-line handle-callback-err
// Delete old index entry
search.delete(entryPath)
// Create cache for new entry
return self.updateCache(newEntryPath).then(entry => {
return search.add(entry)
})
})
})
},
/**
* Generate a starter page content based on the entry path
*
* @param {String} entryPath The entry path
* @return {Promise<String>} Starter content
*/
getStarter (entryPath) {
let formattedTitle = _.startCase(_.last(_.split(entryPath, '/')))
return fs.readFileAsync(path.join(ROOTPATH, 'client/content/create.md'), 'utf8').then((contents) => {
return _.replace(contents, new RegExp('{TITLE}', 'g'), formattedTitle)
})
},
/**
* Get all entries from base path
*
* @param {String} basePath Path to list from
* @return {Promise<Array>} List of entries
*/
getFromTree (basePath) {
return db.Entry.find({ parentPath: basePath }, 'title parentPath isDirectory isEntry').sort({ title: 'asc' })
}
}
+255
View File
@@ -0,0 +1,255 @@
'use strict'
const Git = require('git-wrapper2-promise')
const Promise = require('bluebird')
const path = require('path')
const fs = Promise.promisifyAll(require('fs'))
const _ = require('lodash')
const URL = require('url')
/**
* Git Model
*/
module.exports = {
_git: null,
_url: '',
_repo: {
path: '',
branch: 'master',
exists: false
},
_signature: {
email: 'wiki@example.com'
},
_opts: {
clone: {},
push: {}
},
onReady: null,
/**
* Initialize Git model
*
* @return {Object} Git model instance
*/
init () {
let self = this
// -> Build repository path
if (_.isEmpty(appconfig.paths.repo)) {
self._repo.path = path.join(ROOTPATH, 'repo')
} else {
self._repo.path = appconfig.paths.repo
}
// -> Initialize repository
self.onReady = self._initRepo(appconfig)
// Define signature
if (appconfig.git) {
self._signature.email = appconfig.git.serverEmail || 'wiki@example.com'
}
return self
},
/**
* Initialize Git repository
*
* @param {Object} appconfig The application config
* @return {Object} Promise
*/
_initRepo (appconfig) {
let self = this
winston.info('[' + PROCNAME + '.Git] Checking Git repository...')
// -> Check if path is accessible
return fs.mkdirAsync(self._repo.path).catch((err) => {
if (err.code !== 'EEXIST') {
winston.error('[' + PROCNAME + '.Git] Invalid Git repository path or missing permissions.')
}
}).then(() => {
self._git = new Git({ 'git-dir': self._repo.path })
// -> Check if path already contains a git working folder
return self._git.isRepo().then((isRepo) => {
self._repo.exists = isRepo
return (!isRepo) ? self._git.exec('init') : true
}).catch((err) => { // eslint-disable-line handle-callback-err
self._repo.exists = false
})
}).then(() => {
if (appconfig.git === false) {
winston.info('[' + PROCNAME + '.Git] Remote syncing is disabled. Not recommended!')
return Promise.resolve(true)
}
// Initialize remote
let urlObj = URL.parse(appconfig.git.url)
if (appconfig.git.auth.type !== 'ssh') {
urlObj.auth = appconfig.git.auth.username + ':' + appconfig.git.auth.password
}
self._url = URL.format(urlObj)
let gitConfigs = [
() => { return self._git.exec('config', ['--local', 'user.name', 'Wiki']) },
() => { return self._git.exec('config', ['--local', 'user.email', self._signature.email]) },
() => { return self._git.exec('config', ['--local', '--bool', 'http.sslVerify', _.toString(appconfig.git.auth.sslVerify)]) }
]
if (appconfig.git.auth.type === 'ssh') {
gitConfigs.push(() => {
return self._git.exec('config', ['--local', 'core.sshCommand', 'ssh -i "' + appconfig.git.auth.privateKey + '" -o StrictHostKeyChecking=no'])
})
}
return self._git.exec('remote', 'show').then((cProc) => {
let out = cProc.stdout.toString()
return Promise.each(gitConfigs, fn => { return fn() }).then(() => {
if (!_.includes(out, 'origin')) {
return self._git.exec('remote', ['add', 'origin', self._url])
} else {
return self._git.exec('remote', ['set-url', 'origin', self._url])
}
}).catch(err => {
winston.error(err)
})
})
}).catch((err) => {
winston.error('[' + PROCNAME + '.Git] Git remote error!')
throw err
}).then(() => {
winston.info('[' + PROCNAME + '.Git] Git repository is OK.')
return true
})
},
/**
* Gets the repo path.
*
* @return {String} The repo path.
*/
getRepoPath () {
return this._repo.path || path.join(ROOTPATH, 'repo')
},
/**
* Sync with the remote repository
*
* @return {Promise} Resolve on sync success
*/
resync () {
let self = this
// Is git remote disabled?
if (appconfig.git === false) {
return Promise.resolve(true)
}
// Fetch
winston.info('[' + PROCNAME + '.Git] Performing pull from remote repository...')
return self._git.pull('origin', self._repo.branch).then((cProc) => {
winston.info('[' + PROCNAME + '.Git] Pull completed.')
})
.catch((err) => {
winston.error('[' + PROCNAME + '.Git] Unable to fetch from git origin!')
throw err
})
.then(() => {
// Check for changes
return self._git.exec('log', 'origin/' + self._repo.branch + '..HEAD').then((cProc) => {
let out = cProc.stdout.toString()
if (_.includes(out, 'commit')) {
winston.info('[' + PROCNAME + '.Git] Performing push to remote repository...')
return self._git.push('origin', self._repo.branch).then(() => {
return winston.info('[' + PROCNAME + '.Git] Push completed.')
})
} else {
winston.info('[' + PROCNAME + '.Git] Push skipped. Repository is already in sync.')
}
return true
})
})
.catch((err) => {
winston.error('[' + PROCNAME + '.Git] Unable to push changes to remote!')
throw err
})
},
/**
* Commits a document.
*
* @param {String} entryPath The entry path
* @return {Promise} Resolve on commit success
*/
commitDocument (entryPath, author) {
let self = this
let gitFilePath = entryPath + '.md'
let commitMsg = ''
return self._git.exec('ls-files', gitFilePath).then((cProc) => {
let out = cProc.stdout.toString()
return _.includes(out, gitFilePath)
}).then((isTracked) => {
commitMsg = (isTracked) ? 'Updated ' + gitFilePath : 'Added ' + gitFilePath
return self._git.add(gitFilePath)
}).then(() => {
return self._git.exec('commit', ['-m', commitMsg, '--author="' + author.name + ' <' + author.email + '>"']).catch((err) => {
if (_.includes(err.stdout, 'nothing to commit')) { return true }
})
})
},
/**
* Move a document.
*
* @param {String} entryPath The current entry path
* @param {String} newEntryPath The new entry path
* @return {Promise<Boolean>} Resolve on success
*/
moveDocument (entryPath, newEntryPath) {
let self = this
let gitFilePath = entryPath + '.md'
let gitNewFilePath = newEntryPath + '.md'
return self._git.exec('mv', [gitFilePath, gitNewFilePath]).then((cProc) => {
let out = cProc.stdout.toString()
if (_.includes(out, 'fatal')) {
let errorMsg = _.capitalize(_.head(_.split(_.replace(out, 'fatal: ', ''), ',')))
throw new Error(errorMsg)
}
return true
})
},
/**
* Commits uploads changes.
*
* @param {String} msg The commit message
* @return {Promise} Resolve on commit success
*/
commitUploads (msg) {
let self = this
msg = msg || 'Uploads repository sync'
return self._git.add('uploads').then(() => {
return self._git.commit(msg).catch((err) => {
if (_.includes(err.stdout, 'nothing to commit')) { return true }
})
})
}
}
+175
View File
@@ -0,0 +1,175 @@
'use strict'
const path = require('path')
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const multer = require('multer')
const os = require('os')
const _ = require('lodash')
/**
* Local Data Storage
*/
module.exports = {
_uploadsPath: './repo/uploads',
_uploadsThumbsPath: './data/thumbs',
uploadImgHandler: null,
/**
* Initialize Local Data Storage model
*
* @return {Object} Local Data Storage model instance
*/
init () {
this._uploadsPath = path.resolve(ROOTPATH, appconfig.paths.repo, 'uploads')
this._uploadsThumbsPath = path.resolve(ROOTPATH, appconfig.paths.data, 'thumbs')
this.createBaseDirectories(appconfig)
this.initMulter(appconfig)
return this
},
/**
* Init Multer upload handlers
*
* @param {Object} appconfig The application config
* @return {boolean} Void
*/
initMulter (appconfig) {
let maxFileSizes = {
img: appconfig.uploads.maxImageFileSize * 1024 * 1024,
file: appconfig.uploads.maxOtherFileSize * 1024 * 1024
}
// -> IMAGES
this.uploadImgHandler = multer({
storage: multer.diskStorage({
destination: (req, f, cb) => {
cb(null, path.resolve(ROOTPATH, appconfig.paths.data, 'temp-upload'))
}
}),
fileFilter: (req, f, cb) => {
// -> Check filesize
if (f.size > maxFileSizes.img) {
return cb(null, false)
}
// -> Check MIME type (quick check only)
if (!_.includes(['image/png', 'image/jpeg', 'image/gif', 'image/webp'], f.mimetype)) {
return cb(null, false)
}
cb(null, true)
}
}).array('imgfile', 20)
// -> FILES
this.uploadFileHandler = multer({
storage: multer.diskStorage({
destination: (req, f, cb) => {
cb(null, path.resolve(ROOTPATH, appconfig.paths.data, 'temp-upload'))
}
}),
fileFilter: (req, f, cb) => {
// -> Check filesize
if (f.size > maxFileSizes.file) {
return cb(null, false)
}
cb(null, true)
}
}).array('binfile', 20)
return true
},
/**
* Creates a base directories (Synchronous).
*
* @param {Object} appconfig The application config
* @return {Void} Void
*/
createBaseDirectories (appconfig) {
winston.info('[SERVER.Local] Checking data directories...')
try {
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.paths.data))
fs.emptyDirSync(path.resolve(ROOTPATH, appconfig.paths.data))
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.paths.data, './cache'))
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.paths.data, './thumbs'))
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.paths.data, './temp-upload'))
if (os.type() !== 'Windows_NT') {
fs.chmodSync(path.resolve(ROOTPATH, appconfig.paths.data, './temp-upload'), '755')
}
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.paths.repo))
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.paths.repo, './uploads'))
if (os.type() !== 'Windows_NT') {
fs.chmodSync(path.resolve(ROOTPATH, appconfig.paths.repo, './uploads'), '755')
}
} catch (err) {
winston.error(err)
}
winston.info('[SERVER.Local] Data and Repository directories are OK.')
},
/**
* Gets the uploads path.
*
* @return {String} The uploads path.
*/
getUploadsPath () {
return this._uploadsPath
},
/**
* Gets the thumbnails folder path.
*
* @return {String} The thumbs path.
*/
getThumbsPath () {
return this._uploadsThumbsPath
},
/**
* Check if filename is valid and unique
*
* @param {String} f The filename
* @param {String} fld The containing folder
* @param {boolean} isImage Indicates if image
* @return {Promise<String>} Promise of the accepted filename
*/
validateUploadsFilename (f, fld, isImage) {
let fObj = path.parse(f)
let fname = _.chain(fObj.name).trim().toLower().kebabCase().value().replace(new RegExp('(?!([^a-z0-9-]|' + appdata.regex.cjk.source + '))', 'g'), '')
let fext = _.toLower(fObj.ext)
if (isImage && !_.includes(['.jpg', '.jpeg', '.png', '.gif', '.webp'], fext)) {
fext = '.png'
}
f = fname + fext
let fpath = path.resolve(this._uploadsPath, fld, f)
return fs.statAsync(fpath).then((s) => {
throw new Error('File ' + f + ' already exists.')
}).catch((err) => {
if (err.code === 'ENOENT') {
return f
}
throw err
})
}
}
+72
View File
@@ -0,0 +1,72 @@
'use strict'
const winston = require('winston')
module.exports = (isDebug) => {
if (typeof PROCNAME === 'undefined') {
const PROCNAME = 'SERVER' // eslint-disable-line no-unused-vars
}
// Console + File Logs
winston.remove(winston.transports.Console)
winston.add(winston.transports.Console, {
level: (isDebug) ? 'debug' : 'info',
prettyPrint: true,
colorize: true,
silent: false,
timestamp: true,
filters: [(level, msg, meta) => {
return '[' + PROCNAME + '] ' + msg // eslint-disable-line no-undef
}]
})
// External services
if (appconfig.externalLogging.bugsnag) {
const bugsnagTransport = require('./winston-transports/bugsnag')
winston.add(bugsnagTransport, {
level: 'warn',
key: appconfig.externalLogging.bugsnag
})
}
if (appconfig.externalLogging.loggly) {
require('winston-loggly-bulk')
winston.add(winston.transports.Loggly, {
token: appconfig.externalLogging.loggly.token,
subdomain: appconfig.externalLogging.loggly.subdomain,
tags: ['wiki-js'],
level: 'warn',
json: true
})
}
if (appconfig.externalLogging.papertrail) {
require('winston-papertrail').Papertrail // eslint-disable-line no-unused-expressions
winston.add(winston.transports.Papertrail, {
host: appconfig.externalLogging.papertrail.host,
port: appconfig.externalLogging.papertrail.port,
level: 'warn',
program: 'wiki.js'
})
}
if (appconfig.externalLogging.rollbar) {
const rollbarTransport = require('./winston-transports/rollbar')
winston.add(rollbarTransport, {
level: 'warn',
key: appconfig.externalLogging.rollbar
})
}
if (appconfig.externalLogging.sentry) {
const sentryTransport = require('./winston-transports/sentry')
winston.add(sentryTransport, {
level: 'warn',
key: appconfig.externalLogging.sentry
})
}
return winston
}
+328
View File
@@ -0,0 +1,328 @@
'use strict'
const md = require('markdown-it')
const mdEmoji = require('markdown-it-emoji')
const mdTaskLists = require('markdown-it-task-lists')
const mdAbbr = require('markdown-it-abbr')
const mdAnchor = require('markdown-it-anchor')
const mdFootnote = require('markdown-it-footnote')
const mdExternalLinks = require('markdown-it-external-links')
const mdExpandTabs = require('markdown-it-expand-tabs')
const mdAttrs = require('markdown-it-attrs')
const hljs = require('highlight.js')
const cheerio = require('cheerio')
const _ = require('lodash')
const mdRemove = require('remove-markdown')
// Load plugins
var mkdown = md({
html: true,
linkify: true,
typography: true,
highlight (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' + hljs.highlight(lang, str, true).value + '</code></pre>'
} catch (err) {
return '<pre><code>' + str + '</code></pre>'
}
}
return '<pre><code>' + str + '</code></pre>'
}
})
.use(mdEmoji)
.use(mdTaskLists)
.use(mdAbbr)
.use(mdAnchor, {
slugify: _.kebabCase,
permalink: true,
permalinkClass: 'toc-anchor icon-anchor',
permalinkSymbol: '',
permalinkBefore: true
})
.use(mdFootnote)
.use(mdExternalLinks, {
externalClassName: 'external-link',
internalClassName: 'internal-link'
})
.use(mdExpandTabs, {
tabWidth: 4
})
.use(mdAttrs)
if (appconfig) {
const mdMathjax = require('markdown-it-mathjax')
mkdown.use(mdMathjax())
}
// Rendering rules
mkdown.renderer.rules.emoji = function (token, idx) {
return '<i class="twa twa-' + _.replace(token[idx].markup, /_/g, '-') + '"></i>'
}
// Video rules
const videoRules = [
{
selector: 'a.youtube',
regexp: new RegExp(/(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v(?:i)?=|&v(?:i)?=))([^#&?]*).*/i),
output: '<iframe width="640" height="360" src="https://www.youtube.com/embed/{0}?rel=0" frameborder="0" allowfullscreen></iframe>'
},
{
selector: 'a.vimeo',
regexp: new RegExp(/vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/(?:[^/]*)\/videos\/|album\/(?:\d+)\/video\/|)(\d+)(?:$|\/|\?)/i),
output: '<iframe src="https://player.vimeo.com/video/{0}" width="640" height="360" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>'
},
{
selector: 'a.dailymotion',
regexp: new RegExp(/(?:dailymotion\.com(?:\/embed)?(?:\/video|\/hub)|dai\.ly)\/([0-9a-z]+)(?:[-_0-9a-zA-Z]+(?:#video=)?([a-z0-9]+)?)?/i),
output: '<iframe width="640" height="360" src="//www.dailymotion.com/embed/video/{0}?endscreen-enable=false" frameborder="0" allowfullscreen></iframe>'
},
{
selector: 'a.video',
regexp: false,
output: '<video width="640" height="360" controls preload="metadata"><source src="{0}" type="video/mp4"></video>'
}
]
/**
* Parse markdown content and build TOC tree
*
* @param {(Function|string)} content Markdown content
* @return {Array} TOC tree
*/
const parseTree = (content) => {
let tokens = md().parse(content, {})
let tocArray = []
// -> Extract headings and their respective levels
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'heading_close') {
continue
}
const heading = tokens[i - 1]
const headingclose = tokens[i]
if (heading.type === 'inline') {
let content = ''
let anchor = ''
if (heading.children && heading.children[0].type === 'link_open') {
content = heading.children[1].content
anchor = _.kebabCase(content)
} else {
content = heading.content
anchor = _.kebabCase(heading.children.reduce((acc, t) => acc + t.content, ''))
}
tocArray.push({
content,
anchor,
level: +headingclose.tag.substr(1, 1)
})
}
}
// -> Exclude levels deeper than 2
_.remove(tocArray, (n) => { return n.level > 2 })
// -> Build tree from flat array
return _.reduce(tocArray, (tree, v) => {
let treeLength = tree.length - 1
if (v.level < 2) {
tree.push({
content: v.content,
anchor: v.anchor,
nodes: []
})
} else {
let lastNodeLevel = 1
let GetNodePath = (startPos) => {
lastNodeLevel++
if (_.isEmpty(startPos)) {
startPos = 'nodes'
}
if (lastNodeLevel === v.level) {
return startPos
} else {
return GetNodePath(startPos + '[' + (_.at(tree[treeLength], startPos).length - 1) + '].nodes')
}
}
let lastNodePath = GetNodePath()
let lastNode = _.get(tree[treeLength], lastNodePath)
if (lastNode) {
lastNode.push({
content: v.content,
anchor: v.anchor,
nodes: []
})
_.set(tree[treeLength], lastNodePath, lastNode)
}
}
return tree
}, [])
}
/**
* Parse markdown content to HTML
*
* @param {String} content Markdown content
* @return {String} HTML formatted content
*/
const parseContent = (content) => {
let output = mkdown.render(content)
let cr = cheerio.load(output)
if (cr.root().children().length < 1) {
return ''
}
// -> Check for empty first element
let firstElm = cr.root().children().first()[0]
if (firstElm.type === 'tag' && firstElm.name === 'p') {
let firstElmChildren = firstElm.children
if (firstElmChildren.length < 1) {
firstElm.remove()
} else if (firstElmChildren.length === 1 && firstElmChildren[0].type === 'tag' && firstElmChildren[0].name === 'img') {
cr(firstElm).addClass('is-gapless')
}
}
// -> Remove links in headers
cr('h1 > a:not(.toc-anchor), h2 > a:not(.toc-anchor), h3 > a:not(.toc-anchor)').each((i, elm) => {
let txtLink = cr(elm).text()
cr(elm).replaceWith(txtLink)
})
// -> Re-attach blockquote styling classes to their parents
cr.root().children('blockquote').each((i, elm) => {
if (cr(elm).children().length > 0) {
let bqLastChild = cr(elm).children().last()[0]
let bqLastChildClasses = cr(bqLastChild).attr('class')
if (bqLastChildClasses && bqLastChildClasses.length > 0) {
cr(bqLastChild).removeAttr('class')
cr(elm).addClass(bqLastChildClasses)
}
}
})
// -> Enclose content below headers
cr('h2').each((i, elm) => {
let subH2Content = cr(elm).nextUntil('h1, h2')
cr(elm).after('<div class="indent-h2"></div>')
let subH2Container = cr(elm).next('.indent-h2')
_.forEach(subH2Content, (ch) => {
cr(subH2Container).append(ch)
})
})
cr('h3').each((i, elm) => {
let subH3Content = cr(elm).nextUntil('h1, h2, h3')
cr(elm).after('<div class="indent-h3"></div>')
let subH3Container = cr(elm).next('.indent-h3')
_.forEach(subH3Content, (ch) => {
cr(subH3Container).append(ch)
})
})
// Replace video links with embeds
_.forEach(videoRules, (vrule) => {
cr(vrule.selector).each((i, elm) => {
let originLink = cr(elm).attr('href')
if (vrule.regexp) {
let vidMatches = originLink.match(vrule.regexp)
if ((vidMatches && _.isArray(vidMatches))) {
vidMatches = _.filter(vidMatches, (f) => {
return f && _.isString(f)
})
originLink = _.last(vidMatches)
}
}
let processedLink = _.replace(vrule.output, '{0}', originLink)
cr(elm).replaceWith(processedLink)
})
})
// Apply align-center to parent
cr('img.align-center').each((i, elm) => {
cr(elm).parent().addClass('align-center')
cr(elm).removeClass('align-center')
})
output = cr.html()
return output
}
/**
* Parse meta-data tags from content
*
* @param {String} content Markdown content
* @return {Object} Properties found in the content and their values
*/
const parseMeta = (content) => {
let commentMeta = new RegExp('<!-- ?([a-zA-Z]+):(.*)-->', 'g')
let results = {}
let match
while ((match = commentMeta.exec(content)) !== null) {
results[_.toLower(match[1])] = _.trim(match[2])
}
return results
}
module.exports = {
/**
* Parse content and return all data
*
* @param {String} content Markdown-formatted content
* @return {Object} Object containing meta, html and tree data
*/
parse (content) {
return {
meta: parseMeta(content),
html: parseContent(content),
tree: parseTree(content)
}
},
parseContent,
parseMeta,
parseTree,
/**
* Strips non-text elements from Markdown content
*
* @param {String} content Markdown-formatted content
* @return {String} Text-only version
*/
removeMarkdown (content) {
return mdRemove(_.chain(content)
.replace(/<!-- ?([a-zA-Z]+):(.*)-->/g, '')
.replace(/```[^`]+```/g, '')
.replace(/`[^`]+`/g, '')
.replace(new RegExp('(?!mailto:)(?:(?:http|https|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?', 'g'), '')
.replace(/\r?\n|\r/g, ' ')
.deburr()
.toLower()
.replace(/(\b([^a-z]+)\b)/g, ' ')
.replace(/[^a-z]+/g, ' ')
.replace(/(\b(\w{1,2})\b(\W|$))/g, '')
.replace(/\s\s+/g, ' ')
.value()
)
}
}
+120
View File
@@ -0,0 +1,120 @@
'use strict'
/* global db */
const _ = require('lodash')
/**
* Rights
*/
module.exports = {
guest: {
provider: 'local',
email: 'guest',
name: 'Guest',
password: '',
rights: [
{
role: 'read',
path: '/',
deny: false,
exact: false
}
]
},
/**
* Initialize Rights module
*
* @return {void} Void
*/
init () {
let self = this
db.onReady.then(() => {
db.User.findOne({ provider: 'local', email: 'guest' }).then((u) => {
if (u) {
self.guest = u
}
})
})
},
/**
* Check user permissions for this request
*
* @param {object} req The request object
* @return {object} List of permissions for this request
*/
check (req) {
let self = this
let perm = {
read: false,
write: false,
manage: false
}
let rt = []
let p = _.chain(req.originalUrl).toLower().trim().value()
// Load User Rights
if (_.isArray(req.user.rights)) {
rt = req.user.rights
}
// Is admin?
if (_.find(rt, { role: 'admin' })) {
perm.read = true
perm.write = true
perm.manage = true
} else if (self.checkRole(p, rt, 'write')) {
perm.read = true
perm.write = true
} else if (self.checkRole(p, rt, 'read')) {
perm.read = true
}
return perm
},
/**
* Check for a specific role based on list of user rights
*
* @param {String} p Base path
* @param {array<object>} rt The user rights
* @param {string} role The minimum role required
* @return {boolean} True if authorized
*/
checkRole (p, rt, role) {
// Check specific role on path
let filteredRights = _.filter(rt, (r) => {
if (r.role === role || (r.role === 'write' && role === 'read')) {
if ((!r.exact && _.startsWith(p, r.path)) || (r.exact && p === r.path)) {
return true
}
}
return false
})
// Check for deny scenario
let isValid = false
if (filteredRights.length > 1) {
isValid = !_.chain(filteredRights).sortBy((r) => {
return r.path.length + ((r.deny) ? 0.5 : 0)
}).last().get('deny').value()
} else if (filteredRights.length === 1 && filteredRights[0].deny === false) {
isValid = true
}
// Deny by default
return isValid
}
}
+81
View File
@@ -0,0 +1,81 @@
const bunyan = require('bunyan')
const level = require('levelup')
const down = require('memdown')
const SearchIndexAdder = require('search-index-adder')
const SearchIndexSearcher = require('search-index-searcher')
module.exports = function (givenOptions, moduleReady) {
const optionsLoaded = function (err, SearchIndex) {
const siUtil = require('./siUtil.js')(SearchIndex.options)
if (err) return moduleReady(err)
SearchIndex.close = siUtil.close
SearchIndex.countDocs = siUtil.countDocs
getAdder(SearchIndex, adderLoaded)
}
const adderLoaded = function (err, SearchIndex) {
if (err) return moduleReady(err)
getSearcher(SearchIndex, searcherLoaded)
}
const searcherLoaded = function (err, SearchIndex) {
if (err) return moduleReady(err)
return moduleReady(err, SearchIndex)
}
getOptions(givenOptions, optionsLoaded)
}
const getAdder = function (SearchIndex, done) {
SearchIndexAdder(SearchIndex.options, function (err, searchIndexAdder) {
SearchIndex.add = searchIndexAdder.add
SearchIndex.callbackyAdd = searchIndexAdder.concurrentAdd // deprecated
SearchIndex.concurrentAdd = searchIndexAdder.concurrentAdd
SearchIndex.createWriteStream = searchIndexAdder.createWriteStream
SearchIndex.dbWriteStream = searchIndexAdder.dbWriteStream
SearchIndex.defaultPipeline = searchIndexAdder.defaultPipeline
SearchIndex.del = searchIndexAdder.deleter
SearchIndex.deleteStream = searchIndexAdder.deleteStream
SearchIndex.flush = searchIndexAdder.flush
done(err, SearchIndex)
})
}
const getSearcher = function (SearchIndex, done) {
SearchIndexSearcher(SearchIndex.options, function (err, searchIndexSearcher) {
SearchIndex.availableFields = searchIndexSearcher.availableFields
SearchIndex.buckets = searchIndexSearcher.bucketStream
SearchIndex.categorize = searchIndexSearcher.categoryStream
SearchIndex.dbReadStream = searchIndexSearcher.dbReadStream
SearchIndex.get = searchIndexSearcher.get
SearchIndex.match = searchIndexSearcher.match
SearchIndex.scan = searchIndexSearcher.scan
SearchIndex.search = searchIndexSearcher.search
SearchIndex.totalHits = searchIndexSearcher.totalHits
done(err, SearchIndex)
})
}
const getOptions = function (options, done) {
var SearchIndex = {}
SearchIndex.options = Object.assign({}, {
indexPath: 'si',
keySeparator: '○',
logLevel: 'error'
}, options)
options.log = bunyan.createLogger({
name: 'search-index',
level: options.logLevel
})
if (!options.indexes) {
level(SearchIndex.options.indexPath || 'si', {
valueEncoding: 'json',
db: down
}, function (err, db) {
SearchIndex.options.indexes = db
return done(err, SearchIndex)
})
} else {
return done(null, SearchIndex)
}
}
+36
View File
@@ -0,0 +1,36 @@
'use strict'
module.exports = function (siOptions) {
var siUtil = {}
siUtil.countDocs = function (callback) {
var count = 0
const gte = 'DOCUMENT' + siOptions.keySeparator
const lte = 'DOCUMENT' + siOptions.keySeparator + siOptions.keySeparator
siOptions.indexes.createReadStream({gte: gte, lte: lte})
.on('data', function (data) {
count++
})
.on('error', function (err) {
return callback(err, null)
})
.on('end', function () {
return callback(null, count)
})
}
siUtil.close = function (callback) {
siOptions.indexes.close(function (err) {
while (!siOptions.indexes.isClosed()) {
// log not always working here- investigate
if (siOptions.log) siOptions.log.info('closing...')
}
if (siOptions.indexes.isClosed()) {
if (siOptions.log) siOptions.log.info('closed...')
callback(err)
}
})
}
return siUtil
}
+208
View File
@@ -0,0 +1,208 @@
'use strict'
const Promise = require('bluebird')
const _ = require('lodash')
const searchIndex = require('./search-index')
const stopWord = require('stopword')
const streamToPromise = require('stream-to-promise')
module.exports = {
_si: null,
_isReady: false,
/**
* Initialize search index
*
* @return {undefined} Void
*/
init () {
let self = this
self._isReady = new Promise((resolve, reject) => {
searchIndex({
deletable: true,
fieldedSearch: true,
indexPath: 'wiki',
logLevel: 'error',
stopwords: _.get(stopWord, appconfig.lang, [])
}, (err, si) => {
if (err) {
winston.error('[SERVER.Search] Failed to initialize search index.', err)
reject(err)
} else {
self._si = Promise.promisifyAll(si)
self._si.flushAsync().then(() => {
winston.info('[SERVER.Search] Search index flushed and ready.')
resolve(true)
})
}
})
})
return self
},
/**
* Add a document to the index
*
* @param {Object} content Document content
* @return {Promise} Promise of the add operation
*/
add (content) {
let self = this
if (!content.isEntry) {
return Promise.resolve(true)
}
return self._isReady.then(() => {
return self.delete(content._id).then(() => {
return self._si.concurrentAddAsync({
fieldOptions: [{
fieldName: 'entryPath',
searchable: true,
weight: 2
},
{
fieldName: 'title',
nGramLength: [1, 2],
searchable: true,
weight: 3
},
{
fieldName: 'subtitle',
searchable: true,
weight: 1,
storeable: false
},
{
fieldName: 'parent',
searchable: false
},
{
fieldName: 'content',
searchable: true,
weight: 0,
storeable: false
}]
}, [{
entryPath: content._id,
title: content.title,
subtitle: content.subtitle || '',
parent: content.parent || '',
content: content.content || ''
}]).then(() => {
winston.log('verbose', '[SERVER.Search] Entry ' + content._id + ' added/updated to index.')
return true
}).catch((err) => {
winston.error(err)
})
}).catch((err) => {
winston.error(err)
})
})
},
/**
* Delete an entry from the index
*
* @param {String} The entry path
* @return {Promise} Promise of the operation
*/
delete (entryPath) {
let self = this
return self._isReady.then(() => {
return streamToPromise(self._si.search({
query: [{
AND: { 'entryPath': [entryPath] }
}]
})).then((results) => {
if (results && results.length > 0) {
let delIds = _.map(results, 'id')
return self._si.delAsync(delIds)
} else {
return true
}
}).catch((err) => {
if (err.type === 'NotFoundError') {
return true
} else {
winston.error(err)
}
})
})
},
/**
* Flush the index
*
* @returns {Promise} Promise of the flush operation
*/
flush () {
let self = this
return self._isReady.then(() => {
return self._si.flushAsync()
})
},
/**
* Search the index
*
* @param {Array<String>} terms
* @returns {Promise<Object>} Hits and suggestions
*/
find (terms) {
let self = this
terms = _.chain(terms)
.deburr()
.toLower()
.trim()
.replace(/[^a-z0-9 ]/g, '')
.value()
let arrTerms = _.chain(terms)
.split(' ')
.filter((f) => { return !_.isEmpty(f) })
.value()
return streamToPromise(self._si.search({
query: [{
AND: { '*': arrTerms }
}],
pageSize: 10
})).then((hits) => {
if (hits.length > 0) {
hits = _.map(_.sortBy(hits, ['score']), h => {
return h.document
})
}
if (hits.length < 5) {
return streamToPromise(self._si.match({
beginsWith: terms,
threshold: 3,
limit: 5,
type: 'simple'
})).then((matches) => {
return {
match: hits,
suggest: matches
}
})
} else {
return {
match: hits,
suggest: []
}
}
}).catch((err) => {
if (err.type === 'NotFoundError') {
return {
match: [],
suggest: []
}
} else {
winston.error(err)
}
})
}
}
+134
View File
@@ -0,0 +1,134 @@
'use strict'
const Promise = require('bluebird')
const crypto = require('crypto')
const fs = Promise.promisifyAll(require('fs-extra'))
const https = require('follow-redirects').https
const klaw = require('klaw')
const path = require('path')
const pm2 = Promise.promisifyAll(require('pm2'))
const tar = require('tar')
const through2 = require('through2')
const zlib = require('zlib')
const _ = require('lodash')
module.exports = {
_remoteFile: 'https://github.com/Requarks/wiki/releases/download/{0}/wiki-js.tar.gz',
_installDir: '',
/**
* Install a version of Wiki.js
*
* @param {any} targetTag The version to install
* @returns {Promise} Promise of the operation
*/
install (targetTag) {
let self = this
self._installDir = path.resolve(ROOTPATH, appconfig.paths.data, 'install')
return fs.ensureDirAsync(self._installDir).then(() => {
return fs.emptyDirAsync(self._installDir)
}).then(() => {
let remoteURL = _.replace(self._remoteFile, '{0}', targetTag)
return new Promise((resolve, reject) => {
/**
* Fetch tarball and extract to temporary folder
*/
https.get(remoteURL, resp => {
if (resp.statusCode !== 200) {
return reject(new Error('Remote file not found'))
}
winston.info('[SERVER.System] Install tarball found. Downloading...')
resp.pipe(zlib.createGunzip())
.pipe(tar.Extract({ path: self._installDir }))
.on('error', err => reject(err))
.on('end', () => {
winston.info('[SERVER.System] Tarball extracted. Comparing files...')
/**
* Replace old files
*/
klaw(self._installDir)
.on('error', err => reject(err))
.on('end', () => {
winston.info('[SERVER.System] All files were updated successfully.')
resolve(true)
})
.pipe(self.replaceFile())
})
})
})
}).then(() => {
winston.info('[SERVER.System] Cleaning install leftovers...')
return fs.removeAsync(self._installDir).then(() => {
winston.info('[SERVER.System] Restarting Wiki.js...')
return pm2.restartAsync('wiki').catch(err => { // eslint-disable-line handle-callback-err
winston.error('Unable to restart Wiki.js via pm2... Do a manual restart!')
process.exit()
})
})
}).catch(err => {
winston.warn(err)
})
},
/**
* Replace file if different
*/
replaceFile () {
let self = this
return through2.obj((item, enc, next) => {
if (!item.stats.isDirectory()) {
self.digestFile(item.path).then(sourceHash => {
let destFilePath = _.replace(item.path, self._installDir, ROOTPATH)
return self.digestFile(destFilePath).then(targetHash => {
if (sourceHash === targetHash) {
winston.log('verbose', '[SERVER.System] Skipping ' + destFilePath)
return fs.removeAsync(item.path).then(() => {
return next() || true
})
} else {
winston.log('verbose', '[SERVER.System] Updating ' + destFilePath + '...')
return fs.moveAsync(item.path, destFilePath, { overwrite: true }).then(() => {
return next() || true
})
}
})
}).catch(err => {
throw err
})
} else {
next()
}
})
},
/**
* Generate the hash of a file
*
* @param {String} filePath The absolute path of the file
* @return {Promise<String>} Promise of the hash result
*/
digestFile: (filePath) => {
return new Promise((resolve, reject) => {
let hash = crypto.createHash('sha1')
hash.setEncoding('hex')
fs.createReadStream(filePath)
.on('error', err => { reject(err) })
.on('end', () => {
hash.end()
resolve(hash.read())
})
.pipe(hash)
}).catch(err => {
if (err.code === 'ENOENT') {
return '0'
} else {
throw err
}
})
}
}
+250
View File
@@ -0,0 +1,250 @@
'use strict'
const path = require('path')
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const readChunk = require('read-chunk')
const fileType = require('file-type')
const mime = require('mime-types')
const crypto = require('crypto')
const chokidar = require('chokidar')
const jimp = require('jimp')
const imageSize = Promise.promisify(require('image-size'))
const _ = require('lodash')
/**
* Uploads - Agent
*/
module.exports = {
_uploadsPath: './repo/uploads',
_uploadsThumbsPath: './data/thumbs',
_watcher: null,
/**
* Initialize Uploads model
*
* @return {Object} Uploads model instance
*/
init () {
let self = this
self._uploadsPath = path.resolve(ROOTPATH, appconfig.paths.repo, 'uploads')
self._uploadsThumbsPath = path.resolve(ROOTPATH, appconfig.paths.data, 'thumbs')
return self
},
/**
* Watch the uploads folder for changes
*
* @return {Void} Void
*/
watch () {
let self = this
self._watcher = chokidar.watch(self._uploadsPath, {
persistent: true,
ignoreInitial: true,
cwd: self._uploadsPath,
depth: 1,
awaitWriteFinish: true
})
// -> Add new upload file
self._watcher.on('add', (p) => {
let pInfo = self.parseUploadsRelPath(p)
return self.processFile(pInfo.folder, pInfo.filename).then((mData) => {
return db.UplFile.findByIdAndUpdate(mData._id, mData, { upsert: true })
}).then(() => {
return git.commitUploads('Uploaded ' + p)
})
})
// -> Remove upload file
self._watcher.on('unlink', (p) => {
return git.commitUploads('Deleted/Renamed ' + p)
})
},
/**
* Initial Uploads scan
*
* @return {Promise<Void>} Promise of the scan operation
*/
initialScan () {
let self = this
return fs.readdirAsync(self._uploadsPath).then((ls) => {
// Get all folders
return Promise.map(ls, (f) => {
return fs.statAsync(path.join(self._uploadsPath, f)).then((s) => { return { filename: f, stat: s } })
}).filter((s) => { return s.stat.isDirectory() }).then((arrDirs) => {
let folderNames = _.map(arrDirs, 'filename')
folderNames.unshift('')
// Add folders to DB
return db.UplFolder.remove({}).then(() => {
return db.UplFolder.insertMany(_.map(folderNames, (f) => {
return {
_id: 'f:' + f,
name: f
}
}))
}).then(() => {
// Travel each directory and scan files
let allFiles = []
return Promise.map(folderNames, (fldName) => {
let fldPath = path.join(self._uploadsPath, fldName)
return fs.readdirAsync(fldPath).then((fList) => {
return Promise.map(fList, (f) => {
return upl.processFile(fldName, f).then((mData) => {
if (mData) {
allFiles.push(mData)
}
return true
})
}, {concurrency: 3})
})
}, {concurrency: 1}).finally(() => {
// Add files to DB
return db.UplFile.remove({}).then(() => {
if (_.isArray(allFiles) && allFiles.length > 0) {
return db.UplFile.insertMany(allFiles)
} else {
return true
}
})
})
})
})
}).then(() => {
// Watch for new changes
return upl.watch()
})
},
/**
* Parse relative Uploads path
*
* @param {String} f Relative Uploads path
* @return {Object} Parsed path (folder and filename)
*/
parseUploadsRelPath (f) {
let fObj = path.parse(f)
return {
folder: fObj.dir,
filename: fObj.base
}
},
/**
* Get metadata from file and generate thumbnails if necessary
*
* @param {String} fldName The folder name
* @param {String} f The filename
* @return {Promise<Object>} Promise of the file metadata
*/
processFile (fldName, f) {
let self = this
let fldPath = path.join(self._uploadsPath, fldName)
let fPath = path.join(fldPath, f)
let fPathObj = path.parse(fPath)
let fUid = crypto.createHash('md5').update(fldName + '/' + f).digest('hex')
return fs.statAsync(fPath).then((s) => {
if (!s.isFile()) { return false }
// Get MIME info
let mimeInfo = fileType(readChunk.sync(fPath, 0, 262))
if (_.isNil(mimeInfo)) {
mimeInfo = {
mime: mime.lookup(fPathObj.ext) || 'application/octet-stream'
}
}
// Images
if (s.size < 3145728) { // ignore files larger than 3MB
if (_.includes(['image/png', 'image/jpeg', 'image/gif', 'image/bmp'], mimeInfo.mime)) {
return self.getImageSize(fPath).then((mImgSize) => {
let cacheThumbnailPath = path.parse(path.join(self._uploadsThumbsPath, fUid + '.png'))
let cacheThumbnailPathStr = path.format(cacheThumbnailPath)
let mData = {
_id: fUid,
category: 'image',
mime: mimeInfo.mime,
extra: mImgSize,
folder: 'f:' + fldName,
filename: f,
basename: fPathObj.name,
filesize: s.size
}
// Generate thumbnail
return fs.statAsync(cacheThumbnailPathStr).then((st) => {
return st.isFile()
}).catch((err) => { // eslint-disable-line handle-callback-err
return false
}).then((thumbExists) => {
return (thumbExists) ? mData : fs.ensureDirAsync(cacheThumbnailPath.dir).then(() => {
return self.generateThumbnail(fPath, cacheThumbnailPathStr)
}).return(mData)
})
})
}
}
// Other Files
return {
_id: fUid,
category: 'binary',
mime: mimeInfo.mime,
folder: 'f:' + fldName,
filename: f,
basename: fPathObj.name,
filesize: s.size
}
})
},
/**
* Generate thumbnail of image
*
* @param {String} sourcePath The source path
* @param {String} destPath The destination path
* @return {Promise<Object>} Promise returning the resized image info
*/
generateThumbnail (sourcePath, destPath) {
return jimp.read(sourcePath).then(img => {
return img
.contain(150, 150)
.write(destPath)
})
},
/**
* Gets the image dimensions.
*
* @param {String} sourcePath The source path
* @return {Object} The image dimensions.
*/
getImageSize (sourcePath) {
return imageSize(sourcePath)
}
}
+279
View File
@@ -0,0 +1,279 @@
'use strict'
const path = require('path')
const Promise = require('bluebird')
const fs = Promise.promisifyAll(require('fs-extra'))
const request = require('request')
const url = require('url')
const crypto = require('crypto')
const _ = require('lodash')
var regFolderName = new RegExp('^[a-z0-9][a-z0-9-]*[a-z0-9]$')
const maxDownloadFileSize = 3145728 // 3 MB
/**
* Uploads
*/
module.exports = {
_uploadsPath: './repo/uploads',
_uploadsThumbsPath: './data/thumbs',
/**
* Initialize Local Data Storage model
*
* @return {Object} Uploads model instance
*/
init () {
this._uploadsPath = path.resolve(ROOTPATH, appconfig.paths.repo, 'uploads')
this._uploadsThumbsPath = path.resolve(ROOTPATH, appconfig.paths.data, 'thumbs')
return this
},
/**
* Gets the thumbnails folder path.
*
* @return {String} The thumbs path.
*/
getThumbsPath () {
return this._uploadsThumbsPath
},
/**
* Gets the uploads folders.
*
* @return {Array<String>} The uploads folders.
*/
getUploadsFolders () {
return db.UplFolder.find({}, 'name').sort('name').exec().then((results) => {
return (results) ? _.map(results, 'name') : [{ name: '' }]
})
},
/**
* Creates an uploads folder.
*
* @param {String} folderName The folder name
* @return {Promise} Promise of the operation
*/
createUploadsFolder (folderName) {
let self = this
folderName = _.kebabCase(_.trim(folderName))
if (_.isEmpty(folderName) || !regFolderName.test(folderName)) {
return Promise.resolve(self.getUploadsFolders())
}
return fs.ensureDirAsync(path.join(self._uploadsPath, folderName)).then(() => {
return db.UplFolder.findOneAndUpdate({
_id: 'f:' + folderName
}, {
name: folderName
}, {
upsert: true
})
}).then(() => {
return self.getUploadsFolders()
})
},
/**
* Check if folder is valid and exists
*
* @param {String} folderName The folder name
* @return {Boolean} True if valid
*/
validateUploadsFolder (folderName) {
return db.UplFolder.findOne({ name: folderName }).then((f) => {
return (f) ? path.resolve(this._uploadsPath, folderName) : false
})
},
/**
* Adds one or more uploads files.
*
* @param {Array<Object>} arrFiles The uploads files
* @return {Void} Void
*/
addUploadsFiles (arrFiles) {
if (_.isArray(arrFiles) || _.isPlainObject(arrFiles)) {
// this._uploadsDb.Files.insert(arrFiles);
}
},
/**
* Gets the uploads files.
*
* @param {String} cat Category type
* @param {String} fld Folder
* @return {Array<Object>} The files matching the query
*/
getUploadsFiles (cat, fld) {
return db.UplFile.find({
category: cat,
folder: 'f:' + fld
}).sort('filename').exec()
},
/**
* Deletes an uploads file.
*
* @param {string} uid The file unique ID
* @return {Promise} Promise of the operation
*/
deleteUploadsFile (uid) {
let self = this
return db.UplFile.findOneAndRemove({ _id: uid }).then((f) => {
if (f) {
return self.deleteUploadsFileTry(f, 0)
}
return true
})
},
deleteUploadsFileTry (f, attempt) {
let self = this
let fFolder = (f.folder && f.folder !== 'f:') ? f.folder.slice(2) : './'
return Promise.join(
fs.removeAsync(path.join(self._uploadsThumbsPath, f._id + '.png')),
fs.removeAsync(path.resolve(self._uploadsPath, fFolder, f.filename))
).catch((err) => {
if (err.code === 'EBUSY' && attempt < 5) {
return Promise.delay(100).then(() => {
return self.deleteUploadsFileTry(f, attempt + 1)
})
} else {
winston.warn('Unable to delete uploads file ' + f.filename + '. File is locked by another process and multiple attempts failed.')
return true
}
})
},
/**
* Downloads a file from url.
*
* @param {String} fFolder The folder
* @param {String} fUrl The full URL
* @return {Promise} Promise of the operation
*/
downloadFromUrl (fFolder, fUrl) {
let fUrlObj = url.parse(fUrl)
let fUrlFilename = _.last(_.split(fUrlObj.pathname, '/'))
let destFolder = _.chain(fFolder).trim().toLower().value()
return upl.validateUploadsFolder(destFolder).then((destFolderPath) => {
if (!destFolderPath) {
return Promise.reject(new Error('Invalid Folder'))
}
return lcdata.validateUploadsFilename(fUrlFilename, destFolder).then((destFilename) => {
let destFilePath = path.resolve(destFolderPath, destFilename)
return new Promise((resolve, reject) => {
let rq = request({
url: fUrl,
method: 'GET',
followRedirect: true,
maxRedirects: 5,
timeout: 10000
})
let destFileStream = fs.createWriteStream(destFilePath)
let curFileSize = 0
rq.on('data', (data) => {
curFileSize += data.length
if (curFileSize > maxDownloadFileSize) {
rq.abort()
destFileStream.destroy()
fs.remove(destFilePath)
reject(new Error('Remote file is too large!'))
}
}).on('error', (err) => {
destFileStream.destroy()
fs.remove(destFilePath)
reject(err)
})
destFileStream.on('finish', () => {
resolve(true)
})
rq.pipe(destFileStream)
})
})
})
},
/**
* Move/Rename a file
*
* @param {String} uid The file ID
* @param {String} fld The destination folder
* @param {String} nFilename The new filename (optional)
* @return {Promise} Promise of the operation
*/
moveUploadsFile (uid, fld, nFilename) {
let self = this
return db.UplFolder.findById('f:' + fld).then((folder) => {
if (folder) {
return db.UplFile.findById(uid).then((originFile) => {
// -> Check if rename is valid
let nameCheck = null
if (nFilename) {
let originFileObj = path.parse(originFile.filename)
nameCheck = lcdata.validateUploadsFilename(nFilename + originFileObj.ext, folder.name)
} else {
nameCheck = Promise.resolve(originFile.filename)
}
return nameCheck.then((destFilename) => {
let originFolder = (originFile.folder && originFile.folder !== 'f:') ? originFile.folder.slice(2) : './'
let sourceFilePath = path.resolve(self._uploadsPath, originFolder, originFile.filename)
let destFilePath = path.resolve(self._uploadsPath, folder.name, destFilename)
let preMoveOps = []
// -> Check for invalid operations
if (sourceFilePath === destFilePath) {
return Promise.reject(new Error('Invalid Operation!'))
}
// -> Delete DB entry
preMoveOps.push(db.UplFile.findByIdAndRemove(uid))
// -> Move thumbnail ahead to avoid re-generation
if (originFile.category === 'image') {
let fUid = crypto.createHash('md5').update(folder.name + '/' + destFilename).digest('hex')
let sourceThumbPath = path.resolve(self._uploadsThumbsPath, originFile._id + '.png')
let destThumbPath = path.resolve(self._uploadsThumbsPath, fUid + '.png')
preMoveOps.push(fs.moveAsync(sourceThumbPath, destThumbPath))
} else {
preMoveOps.push(Promise.resolve(true))
}
// -> Proceed to move actual file
return Promise.all(preMoveOps).then(() => {
return fs.moveAsync(sourceFilePath, destFilePath, {
clobber: false
})
})
})
})
} else {
return Promise.reject(new Error('Invalid Destination Folder'))
}
})
}
}
+20
View File
@@ -0,0 +1,20 @@
'use strict'
const util = require('util')
const winston = require('winston')
const _ = require('lodash')
let BugsnagLogger = winston.transports.BugsnagLogger = function (options) {
this.name = 'bugsnagLogger'
this.level = options.level || 'warn'
this.bugsnag = require('bugsnag')
this.bugsnag.register(options.key)
}
util.inherits(BugsnagLogger, winston.Transport)
BugsnagLogger.prototype.log = function (level, msg, meta, callback) {
this.bugsnag.notify(new Error(msg), _.assignIn(meta, { severity: level }))
callback(null, true)
}
module.exports = BugsnagLogger
+20
View File
@@ -0,0 +1,20 @@
'use strict'
const util = require('util')
const winston = require('winston')
const _ = require('lodash')
let RollbarLogger = winston.transports.RollbarLogger = function (options) {
this.name = 'rollbarLogger'
this.level = options.level || 'warn'
this.rollbar = require('rollbar')
this.rollbar.init(options.key)
}
util.inherits(RollbarLogger, winston.Transport)
RollbarLogger.prototype.log = function (level, msg, meta, callback) {
this.rollbar.handleErrorWithPayloadData(new Error(msg), _.assignIn(meta, { level }))
callback(null, true)
}
module.exports = RollbarLogger
+20
View File
@@ -0,0 +1,20 @@
'use strict'
const util = require('util')
const winston = require('winston')
let SentryLogger = winston.transports.RollbarLogger = function (options) {
this.name = 'sentryLogger'
this.level = options.level || 'warn'
this.raven = require('raven')
this.raven.config(options.key).install()
}
util.inherits(SentryLogger, winston.Transport)
SentryLogger.prototype.log = function (level, msg, meta, callback) {
level = (level === 'warn') ? 'warning' : level
this.raven.captureMessage(msg, { level, extra: meta })
callback(null, true)
}
module.exports = SentryLogger
+11
View File
@@ -0,0 +1,11 @@
{
"providers": {
"local": "Lokal",
"windowslive": "Microsoft-Konto",
"google": "Google-ID",
"facebook": "Facebook",
"github": "GitHub",
"slack": "Locker",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "Wiki",
"headers": {
"overview": "Überblick"
},
"footer": {
"poweredby": "Unterstützt von",
"home": "Zuhause",
"top": "Zurück nach oben"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"providers": {
"local": "Local",
"windowslive": "Microsoft Account",
"azure": "Azure Active Directory",
"google": "Google ID",
"facebook": "Facebook",
"github": "GitHub",
"slack": "Slack",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "Wiki",
"headers": {
"overview": "Overview"
},
"footer": {
"poweredby": "Powered by",
"home": "Home",
"top": "Return to top"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"providers": {
"local": "Local",
"windowslive": "Cuenta de Microsoft",
"google": "ID de google",
"facebook": "Facebook",
"github": "GitHub",
"slack": "Flojo",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "Wiki",
"headers": {
"overview": "Información General"
},
"footer": {
"poweredby": "Energizado por",
"home": "Casa",
"top": "Volver a la cima"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"providers": {
"local": "Local",
"windowslive": "Compte Microsoft",
"google": "Google ID",
"facebook": "Facebook",
"github": "GitHub",
"slack": "Slack",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "Wiki",
"headers": {
"overview": "Vue d'ensemble"
},
"footer": {
"poweredby": "Propulsé par",
"home": "Accueil",
"top": "Retour en haut"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"providers": {
"local": "자체 계정",
"windowslive": "Microsoft 계정",
"google": "Google 계정",
"facebook": "Facebook 계정",
"github": "GitHub 계정",
"slack": "Slack 계정",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "위키",
"headers": {
"overview": "개요"
},
"footer": {
"poweredby": "Powered by",
"home": "홈으로",
"top": "맨 위로"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"providers": {
"local": "Local",
"windowslive": "Conta Microsoft",
"google": "Google ID",
"facebook": "Facebook",
"github": "GitHub",
"slack": "negligente",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "Wiki",
"headers": {
"overview": "Visão geral"
},
"footer": {
"poweredby": "Distribuído por",
"home": "Casa",
"top": "Retornar ao topo"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"providers": {
"local": "Местный",
"windowslive": "Счет Microsoft",
"google": "Google ID",
"facebook": "Facebook",
"github": "GitHub",
"slack": "слабина",
"ldap": "LDAP / Active Directory"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"wiki": "Wiki",
"headers": {
"overview": "Обзор"
},
"footer": {
"poweredby": "Питаться от",
"home": "Главная",
"top": "Вернуться к началу"
}
}
+50
View File
@@ -0,0 +1,50 @@
'use strict'
/* global appdata, rights */
const moment = require('moment-timezone')
/**
* Authentication middleware
*
* @param {Express Request} req Express Request object
* @param {Express Response} res Express Response object
* @param {Function} next Next callback function
* @return {any} void
*/
module.exports = (req, res, next) => {
// Is user authenticated ?
if (!req.isAuthenticated()) {
if (!appdata.capabilities.guest || req.app.locals.appconfig.public !== true) {
return res.redirect('/login')
} else {
req.user = rights.guest
res.locals.isGuest = true
}
} else if (appdata.capabilities.guest) {
res.locals.isGuest = false
}
// Check permissions
if (appdata.capabilities.rights) {
res.locals.rights = rights.check(req)
if (!res.locals.rights.read) {
return res.render('error-forbidden')
}
}
// Set i18n locale
req.i18n.changeLanguage(req.user.lang)
res.locals.userMoment = moment
res.locals.userMoment.locale(req.user.lang)
// Expose user data
res.locals.user = req.user
return next()
}
+15
View File
@@ -0,0 +1,15 @@
'use strict'
/**
* Flash middleware
*
* @param {Express Request} req Express Request object
* @param {Express Response} res Express Response object
* @param {Function} next Next callback function
* @return {any} void
*/
module.exports = (req, res, next) => {
res.locals.appflash = req.flash('alert')
next()
}
+30
View File
@@ -0,0 +1,30 @@
'use strict'
/* global app */
/**
* Security Middleware
*
* @param {Express Request} req Express request object
* @param {Express Response} res Express response object
* @param {Function} next next callback function
* @return {any} void
*/
module.exports = function (req, res, next) {
// -> Disable X-Powered-By
app.disable('x-powered-by')
// -> Disable Frame Embedding
res.set('X-Frame-Options', 'deny')
// -> Re-enable XSS Fitler if disabled
res.set('X-XSS-Protection', '1; mode=block')
// -> Disable MIME-sniffing
res.set('X-Content-Type-Options', 'nosniff')
// -> Disable IE Compatibility Mode
res.set('X-UA-Compatible', 'IE=edge')
return next()
}
+20
View File
@@ -0,0 +1,20 @@
'use strict'
const _ = require('lodash')
/**
* SEO Middleware
*
* @param {Express Request} req Express request object
* @param {Express Response} res Express response object
* @param {Function} next next callback function
* @return {any} void
*/
module.exports = function (req, res, next) {
if (req.path.length > 1 && _.endsWith(req.path, '/')) {
let query = req.url.slice(req.path.length) || ''
res.redirect(301, req.path.slice(0, -1) + query)
} else {
return next()
}
}
+18
View File
@@ -0,0 +1,18 @@
'use strict'
/**
* BruteForce schema
*
* @type {<Mongoose.Schema>}
*/
var bruteForceSchema = Mongoose.Schema({
_id: { type: String, index: 1 },
data: {
count: Number,
lastRequest: Date,
firstRequest: Date
},
expires: { type: Date, index: { expires: '1d' } }
})
module.exports = Mongoose.model('Bruteforce', bruteForceSchema)
+40
View File
@@ -0,0 +1,40 @@
'use strict'
/**
* Entry schema
*
* @type {<Mongoose.Schema>}
*/
var entrySchema = Mongoose.Schema({
_id: String,
title: {
type: String,
required: true,
minlength: 2
},
subtitle: {
type: String,
default: ''
},
parentTitle: {
type: String,
default: ''
},
parentPath: {
type: String,
default: ''
},
isDirectory: {
type: Boolean,
default: false
},
isEntry: {
type: Boolean,
default: false
}
}, {
timestamps: {}
})
module.exports = Mongoose.model('Entry', entrySchema)
+44
View File
@@ -0,0 +1,44 @@
'use strict'
/**
* Upload File schema
*
* @type {<Mongoose.Schema>}
*/
var uplFileSchema = Mongoose.Schema({
_id: String,
category: {
type: String,
required: true,
default: 'binary'
},
mime: {
type: String,
required: true,
default: 'application/octet-stream'
},
extra: {
type: Object
},
folder: {
type: String,
ref: 'UplFolder'
},
filename: {
type: String,
required: true
},
basename: {
type: String,
required: true
},
filesize: {
type: Number,
required: true
}
}, { timestamps: {} })
module.exports = Mongoose.model('UplFile', uplFileSchema)
+19
View File
@@ -0,0 +1,19 @@
'use strict'
/**
* Upload Folder schema
*
* @type {<Mongoose.Schema>}
*/
var uplFolderSchema = Mongoose.Schema({
_id: String,
name: {
type: String,
index: true
}
}, { timestamps: {} })
module.exports = Mongoose.model('UplFolder', uplFolderSchema)
+106
View File
@@ -0,0 +1,106 @@
'use strict'
const Promise = require('bluebird')
const bcrypt = require('bcryptjs-then')
const _ = require('lodash')
/**
* Region schema
*
* @type {<Mongoose.Schema>}
*/
var userSchema = Mongoose.Schema({
email: {
type: String,
required: true,
index: true
},
provider: {
type: String,
required: true
},
providerId: {
type: String
},
password: {
type: String
},
name: {
type: String
},
rights: [{
role: String,
path: String,
exact: Boolean,
deny: Boolean
}]
}, { timestamps: {} })
userSchema.statics.processProfile = (profile) => {
let primaryEmail = ''
if (_.isArray(profile.emails)) {
let e = _.find(profile.emails, ['primary', true])
primaryEmail = (e) ? e.value : _.first(profile.emails).value
} else if (_.isString(profile.email) && profile.email.length > 5) {
primaryEmail = profile.email
} else if (_.isString(profile.mail) && profile.mail.length > 5) {
primaryEmail = profile.mail
} else if (profile.user && profile.user.email && profile.user.email.length > 5) {
primaryEmail = profile.user.email
} else {
return Promise.reject(new Error('Invalid User Email'))
}
profile.provider = _.lowerCase(profile.provider)
primaryEmail = _.toLower(primaryEmail)
return db.User.findOneAndUpdate({
email: primaryEmail,
provider: profile.provider
}, {
email: primaryEmail,
provider: profile.provider,
providerId: profile.id,
name: profile.displayName || _.split(primaryEmail, '@')[0]
}, {
new: true
}).then((user) => {
// Handle unregistered accounts
if (!user && profile.provider !== 'local' && (appconfig.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) {
let nUsr = {
email: primaryEmail,
provider: profile.provider,
providerId: profile.id,
password: '',
name: profile.displayName || profile.name || profile.cn,
rights: [{
role: 'read',
path: '/',
exact: false,
deny: false
}]
}
return db.User.create(nUsr)
}
return user || Promise.reject(new Error('You have not been authorized to login to this site yet.'))
})
}
userSchema.statics.hashPassword = (rawPwd) => {
return bcrypt.hash(rawPwd)
}
userSchema.methods.validatePassword = function (rawPwd) {
return bcrypt.compare(rawPwd, this.password).then((isValid) => {
return (isValid) ? true : Promise.reject(new Error('Invalid Login'))
})
}
module.exports = Mongoose.model('User', userSchema)
+74
View File
@@ -0,0 +1,74 @@
doctype html
html(data-logic='login')
head
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
meta(name='theme-color', content='#009688')
meta(name='msapplication-TileColor', content='#009688')
meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
title= appconfig.title
// Favicon
each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
each favsize in [32, 96, 16]
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
link(rel='manifest', href='/manifest.json')
// JS / CSS
script(type='text/javascript', src='/js/bundle.min.js')
body
#bg
each bg in _.sampleSize([1, 2, 3],3)
div(style='background-image:url(/images/bg_' + bg + '.jpg);')
#root
h1= appconfig.title
h2 Login required
if appflash.length > 0
h3
i.icon-warning-outline
= appflash[0].title
h4= appflash[0].message
if appconfig.auth.local.enabled
form(method='post', action='/login')
input#login-user(type='text', name='email', placeholder='Email / Username')
input#login-pass(type='password', name='password', placeholder='Password')
button(type='submit') Log In
if appconfig.authStrategies.socialEnabled
#social
if appconfig.auth.local.enabled
span Or, log in using...
else
span Log in using...
if appconfig.auth.microsoft && appconfig.auth.microsoft.enabled
button.ms(onclick='window.location.assign("/login/ms")')
i.icon-windows2
span Microsoft Account
if appconfig.auth.azure && appconfig.auth.azure.enabled
button.ms(onclick='window.location.assign("/login/azure")')
i.icon-windows2
span Azure AD
if appconfig.auth.google && appconfig.auth.google.enabled
button.google(onclick='window.location.assign("/login/google")')
i.icon-google
span Google ID
if appconfig.auth.facebook && appconfig.auth.facebook.enabled
button.facebook(onclick='window.location.assign("/login/facebook")')
i.icon-facebook
span Facebook
if appconfig.auth.github && appconfig.auth.github.enabled
button.github(onclick='window.location.assign("/login/github")')
i.icon-github
span GitHub
if appconfig.auth.slack && appconfig.auth.slack.enabled
button.slack(onclick='window.location.assign("/login/slack")')
i.icon-slack
span Slack
#copyright
= t('footer.poweredby') + ' '
a.icon(href='https://github.com/Requarks/wiki')
i.icon-github
a(href='https://wiki.requarks.io/') Wiki.js
+14
View File
@@ -0,0 +1,14 @@
#alerts
ul
template(v-for='aItem in children', track-by='_uid')
li(v-bind:class='aItem.class')
button(v-on:click='acknowledge(aItem._uid)')
strong {{ aItem.title }}
span {{ aItem.message }}
if appflash.length > 0
script(type='text/javascript').
var alertsData = !{JSON.stringify(appflash)};
else
script(type='text/javascript').
var alertsData = [];
+8
View File
@@ -0,0 +1,8 @@
footer.footer
span
= t('footer.poweredby') + ' '
a(href='https://github.com/Requarks/wiki') Wiki.js
| .
ul
li: a(href='/')= t('footer.home')
li: a(href='#root')= t('footer.top')
+34
View File
@@ -0,0 +1,34 @@
#header-container
nav.nav.stickyscroll#header
.nav-left
block rootNavLeft
a.nav-item(href='/')
h1
i.icon-layers
= appconfig.title
.nav-center
block rootNavCenter
.nav-item
p.control(v-bind:class='{ "is-loading": searchload > 0 }')
input.input#search-input(type='text', v-model='searchq', @keyup.esc='closeSearch', @keyup.down='moveDownSearch', @keyup.up='moveUpSearch', @keyup.enter='moveSelectSearch', debounce='400', placeholder='Search...')
span.nav-toggle
span
span
span
.nav-right
block rootNavRight
i.nav-item#notifload
transition(name='searchresults-anim', enter-active-class='slideInDown', leave-active-class='fadeOutUp')
.searchresults.animated(v-show='searchactive', v-cloak, style={'display':'none'})
p.searchresults-label Search Results
ul.searchresults-list
li(v-if='searchres.length === 0')
a: em No results matching your query
li(v-for='sres in searchres', v-bind:class='{ "is-active": searchmovekey === "res." + sres.entryPath }')
a(v-bind:href='"/" + sres.entryPath') {{ sres.title }}
p.searchresults-label(v-if='searchsuggest.length > 0') Did you mean...?
ul.searchresults-list(v-if='searchsuggest.length > 0')
li(v-for='sug in searchsuggest', v-bind:class='{ "is-active": searchmovekey === "sug." + sug }')
a(v-on:click='useSuggestion(sug)') {{ sug }}
View File
+383
View File
@@ -0,0 +1,383 @@
doctype html
html(data-logic='configure')
head
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(charset='UTF-8')
title Wiki.js | Configure
// Favicon
each favsize in [32, 96, 16]
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
// JS / CSS
script(type='text/javascript').
var appconfig = !{JSON.stringify(conf)};
script(type='text/javascript', src='/js/configure.min.js')
body
#root
#header-container
nav.nav#header
.nav-left
a.nav-item
h1
i.icon-layers
| Wiki.js
main
.container
transition(name='tst-welcome')
.welcome(v-if='state === "welcome" || state === "restart"')
img(src='/images/logo.png', alt='Wiki.js')
h2 A modern, lightweight and powerful wiki app built on NodeJS, Git and Markdown
.content(v-cloak)
//- ==============================================
//- WELCOME
//- ==============================================
template(v-if='state === "welcome"')
.panel
h2.panel-title.is-featured
span Welcome!
i(v-if='loading')
.panel-content.is-text
p This installation wizard will guide you through the steps needed to get your wiki up and running in no time!
p Detailed information about installation and usage can be found on the #[a(href='https://docs.wiki.requarks.io/') official documentation site]. #[br] Should you have any question or would like to report something that doesn't look right, feel free to create a new issue on the #[a(href='https://github.com/Requarks/wiki/issues') GitHub project].
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue(v-on:click='proceedToSyscheck', v-bind:disabled='loading') Start
//- ==============================================
//- SYSTEM CHECK
//- ==============================================
template(v-else-if='state === "syscheck"')
.panel
h2.panel-title.is-featured
span System Check
i(v-if='loading')
.panel-content.is-text
p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Checking your system for compatibility...
p(v-if='!loading && syscheck.ok')
ul
li(v-for='rs in syscheck.results') #[i.icon-check] {{rs}}
p(v-if='!loading && syscheck.ok')
i.icon-check
strong Looks good! No issues so far.
p(v-if='!loading && !syscheck.ok') #[i.icon-square-cross] Error: {{ syscheck.error }}
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToWelcome', v-bind:disabled='loading') Back
button.button.is-teal(v-on:click='proceedToSyscheck', v-if='!loading && !syscheck.ok') Check Again
button.button.is-light-blue(v-on:click='proceedToGeneral', v-if='loading || syscheck.ok', v-bind:disabled='loading') Continue
//- ==============================================
//- GENERAL
//- ==============================================
template(v-else-if='state === "general"')
.panel
h2.panel-title.is-featured
span General
i(v-if='loading')
.panel-content.form-sections
section
p.control.is-fullwidth
label.label Site Title
input(type='text', placeholder='e.g. Wiki', v-model='conf.title', data-vv-scope='general', name='ipt-title', v-validate='{ required: true, min: 2 }')
span.desc The site title will appear in the top left corner on every page and within the window title bar.
section
p.control.is-fullwidth
label.label Host
input(type='text', placeholder='http://', v-model='conf.host', data-vv-scope='general', name='ipt-host', v-validate='{ required: true, url: true }')
span.desc The full URL to your wiki, without the trailing slash. E.g.: http://wiki.domain.com. Note that sub-folders are not supported.
section
p.control
label.label Port
input(type='text', placeholder='e.g. 80', v-model.number='conf.port', data-vv-scope='general', name='ipt-port', v-validate='{ required: true, numeric: true, min_value: 0, max_value: 65535 }')
span.desc The port on which Wiki.js will listen to. Usually port 80 if connecting directly, or a random port (e.g. 3000) if using a web server in front of it.<br>Set <strong>0</strong> to use $PORT system environment variable.
section
p.control
label.label Site UI Language
select(v-model='conf.lang')
each lg in langs
option(value=lg.id)= lg.name
span.desc The language in which navigation, help and other UI elements will be displayed.
section
p.control.is-fullwidth
input#ipt-public(type='checkbox', v-model='conf.public', data-vv-scope='general', name='ipt-public')
label.label(for='ipt-public') Public Access
span.desc Should the site be accessible (read only) without login.
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToSyscheck', v-bind:disabled='loading') Back
button.button.is-light-blue(v-on:click='proceedToConsiderations', v-bind:disabled='loading || errors.any("general")') Continue
//- ==============================================
//- CONSIDERATIONS
//- ==============================================
template(v-else-if='state === "considerations"')
.panel
h2.panel-title.is-featured
span Important Considerations
i(v-if='loading')
.panel-content.is-text
h3 Is Wiki.js going to be behind a web server (e.g. nginx / apache / IIS) or proxy?
p
ul
li - Make sure the upload limit is sufficient. Most web servers have a low limit (e.g. 2 MB) by default.
li - Make sure your web server is configured to allow web sockets. Wiki.js will fallback to standard XHR queries if not available.
li - Do not rewrite URLs after the domain. This can cause unexpected issues in Wiki.js navigation.
li - Do not remove or alter the client IP when proxying the requests. This can cause the authentication brute force protection to engage unexpectedly.
template(v-if='considerations.https')
h3 The site will not be using HTTPS? #[i.icon-warning-outline.animated.fadeOut.infinite]
p The host URL you specified is not HTTPS. It is highly recommended to use HTTPS. You must use a web server / proxy (e.g. nginx / apache / IIS) in front of Wiki.js to use HTTPS. Wiki.js does not provide HTTPS handling by itself.
template(v-if='considerations.port')
h3 You are using a non-standard port.
p If you are not planning on using a web server / proxy in front of Wiki.js, be aware that users will need to specify the port when accessing the wiki. Make sure this is the intended behavior. Otherwise set a standard HTTP port such as 80.
template(v-if='considerations.localhost')
h3 Are you sure you want to use localhost as the host base URL? #[i.icon-warning-outline.animated.fadeOut.infinite]
p The host URL you specified is localhost. Unless you are a developer running Wiki.js locally on your machine, this is not recommended!
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToGeneral', v-bind:disabled='loading') Back
button.button.is-light-blue(v-on:click='proceedToDb', v-bind:disabled='loading') Continue
//- ==============================================
//- DATABASE
//- ==============================================
template(v-else-if='state === "db"')
.panel
h2.panel-title.is-featured
span Database
i(v-if='loading')
.panel-content.is-text
p Wiki.js stores administrative data such as users, permissions and assets metadata in a MongoDB database. Article contents and uploads are <u>not</u> stored in the DB. Instead, they are stored on-disk and synced automatically with a remote git repository of your choice.
.panel-content.form-sections
section
p.control.is-fullwidth
label.label MongoDB Connection String
input(type='text', placeholder='e.g. mongodb://localhost:27017/wiki', v-model='conf.db', data-vv-scope='db', name='ipt-db', v-validate='{ required: true, min: 3 }')
span.desc The connection string to your MongoDB server. Leave the default localhost value if MongoDB is installed on the same server.<br />You can also specify an environment variable as the connection string (e.g. $MONGO_URI).
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToConsiderations', v-bind:disabled='loading') Back
button.button.is-light-blue(v-on:click='proceedToDbcheck', v-bind:disabled='loading || errors.any("db")') Connect
//- ==============================================
//- DATABASE CHECK
//- ==============================================
template(v-else-if='state === "dbcheck"')
.panel
h2.panel-title.is-featured
span Database Check
i(v-if='loading')
.panel-content.is-text
p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Testing the connection to MongoDB...
p(v-if='!loading && dbcheck.ok')
i.icon-check
strong Connected successfully!
p(v-if='!loading && !dbcheck.ok') #[i.icon-square-cross] Error: {{ dbcheck.error }}
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToDb', v-bind:disabled='loading') Back
button.button.is-teal(v-on:click='proceedToDbcheck', v-if='!loading && !dbcheck.ok') Try Again
button.button.is-light-blue(v-on:click='proceedToPaths', v-if='loading || dbcheck.ok', v-bind:disabled='loading') Continue
//- ==============================================
//- PATHS
//- ==============================================
template(v-else-if='state === "paths"')
.panel
h2.panel-title.is-featured
span Paths
i(v-if='loading')
.panel-content.is-text
p It is recommended to leave the default values.
.panel-content.form-sections
section
p.control.is-fullwidth
label.label Local Data Path
input(type='text', placeholder='e.g. ./data', v-model='conf.pathData', data-vv-scope='paths', name='ipt-datapath', v-validate='{ required: true, min: 2 }')
span.desc The path where cache (processed content, thumbnails, search index, etc.) will be stored on disk.
section
p.control.is-fullwidth
label.label Local Repository Path
input(type='text', placeholder='e.g. ./repo', v-model='conf.pathRepo', data-vv-scope='paths', name='ipt-repopath', v-validate='{ required: true, min: 2 }')
span.desc The path where the local git repository will be created, used to store content in markdown files and uploads.
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToDb', v-bind:disabled='loading') Back
button.button.is-light-blue(v-on:click='proceedToGit', v-bind:disabled='loading || errors.any("paths")') Continue
//- ==============================================
//- GIT
//- ==============================================
template(v-else-if='state === "git"')
.panel
h2.panel-title.is-featured
span Git Repository
i(v-if='loading')
.panel-content.is-text
p Wiki.js stores article content and uploads locally on disk. All content is then regularly kept in sync with a remote git repository. This acts a backup protection and provides history / revert features. While optional, it is <strong>HIGHLY</strong> recommended to setup the remote git repository connection.
.panel-content.form-sections
section.columns
.column.is-two-thirds
p.control.is-fullwidth
label.label Repository URL
input(type='text', placeholder='e.g. git@github.com/org/repo.git or https://github.com/org/repo', v-model='conf.gitUrl', data-vv-scope='git', name='ipt-giturl', v-validate='{ required: true, min: 5 }')
span.desc The full git repository URL to connect to.
.column
p.control.is-fullwidth
label.label Branch
input(type='text', placeholder='e.g. master', v-model='conf.gitBranch', data-vv-scope='git', name='ipt-gitbranch', v-validate='{ required: true, min: 2 }')
span.desc The git branch to use when synchronizing changes.
section.columns
.column.is-one-third
p.control.is-fullwidth
label.label Authentication
select(v-model='conf.gitAuthType')
option(value='ssh') SSH (recommended)
option(value='basic') Basic
span.desc The authentication method used to connect to your remote Git repository.
p.control.is-fullwidth
input#ipt-git-verify-ssl(type='checkbox', v-model='conf.gitAuthSSL')
label.label(for='ipt-git-verify-ssl') Verify SSL
.column(v-show='conf.gitAuthType === "basic"')
p.control.is-fullwidth
label.label Username
input(type='text', v-model='conf.gitAuthUser')
span.desc The username for the remote git connection.
.column(v-show='conf.gitAuthType === "basic"')
p.control.is-fullwidth
label.label Password
input(type='password', v-model='conf.gitAuthPass')
span.desc The password for the remote git connection.
.column(v-show='conf.gitAuthType === "ssh"')
p.control.is-fullwidth
label.label Private Key location
input(type='text', placeholder='e.g. /etc/wiki/keys/git.pem', v-model='conf.gitAuthSSHKey')
span.desc The full path to the private key on disk.
section.columns
.column
p.control.is-fullwidth
label.label Sync User Name
input(type='text', placeholder='e.g. John Smith', v-model.number='conf.gitSignatureName', data-vv-scope='git', name='ipt-gitsigname', v-validate='{ required: true }')
span.desc The name to use when pushing commits to the git repository.
.column
p.control.is-fullwidth
label.label Sync User Email
input(type='text', placeholder='e.g. user@example.com', v-model.number='conf.gitSignatureEmail', data-vv-scope='git', name='ipt-gitsigemail', v-validate='{ required: true, email: true }')
span.desc The email to use when pushing commits to the git repository.
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToPaths', v-bind:disabled='loading') Back
button.button.is-light-blue.is-outlined(v-on:click='conf.gitUseRemote = false; proceedToGitCheck()', v-bind:disabled='loading') Skip this step
button.button.is-light-blue(v-on:click='conf.gitUseRemote = true; proceedToGitCheck()', v-bind:disabled='loading || errors.any("git")') Continue
//- ==============================================
//- GIT CHECK
//- ==============================================
template(v-else-if='state === "gitcheck"')
.panel
h2.panel-title.is-featured
span Git Repository Check
i(v-if='loading')
.panel-content.is-text
p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Verifying Git repository settings...
p(v-if='!loading && gitcheck.ok')
ul
li(v-for='rs in gitcheck.results') #[i.icon-check] {{rs}}
p(v-if='!loading && gitcheck.ok')
i.icon-check
strong Git settings are correct!
p(v-if='!loading && !gitcheck.ok') #[i.icon-square-cross] Error: {{ gitcheck.error }}
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToGit', v-bind:disabled='loading') Back
button.button.is-teal(v-on:click='proceedToGitCheck', v-if='!loading && !gitcheck.ok') Try Again
button.button.is-light-blue(v-on:click='proceedToAdmin', v-if='loading || gitcheck.ok', v-bind:disabled='loading') Continue
//- ==============================================
//- ADMINISTRATOR ACCOUNT
//- ==============================================
template(v-else-if='state === "admin"')
.panel
h2.panel-title.is-featured
span Administrator Account
i(v-if='loading')
.panel-content.is-text
p An administrator account will be created for local authentication. From this account, you can create or authorize more users.
.panel-content.form-sections
section
p.control.is-fullwidth
label.label Administrator Email
input(type='text', placeholder='e.g. admin@example.com', v-model='conf.adminEmail', data-vv-scope='admin', name='ipt-adminemail', v-validate='{ required: true, email: true }')
span.desc The email address of the administrator account
section.columns
.column
p.control.is-fullwidth
label.label Password
input(type='password', v-model='conf.adminPassword', data-vv-scope='admin', name='ipt-adminpwd', v-validate='{ required: true, min: 8 }')
span.desc At least 8 characters long.
.column
p.control.is-fullwidth
label.label Confirm Password
input(type='password', v-model='conf.adminPasswordConfirm', data-vv-scope='admin', name='ipt-adminpwd2', v-validate='{ required: true, confirmed: "ipt-adminpwd" }')
span.desc Verify your password again.
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToGit', v-bind:disabled='loading') Back
button.button.is-light-blue(v-on:click='proceedToFinal', v-bind:disabled='loading || errors.any("admin")') Continue
//- ==============================================
//- FINAL
//- ==============================================
template(v-else-if='state === "final"')
.panel
h2.panel-title.is-featured
span Finalizing
i(v-if='loading')
.panel-content.is-text
p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Finalizing your installation...
p(v-if='!loading && final.ok')
i.icon-check
strong Wiki.js was configured successfully and is now ready for use.
p(v-if='!loading && final.ok')
| Click the <strong>Start</strong> button below to start the Wiki.js server.
p(v-if='!loading && !final.ok') #[i.icon-square-cross] Error: {{ final.error }}
.panel-footer
.progress-bar: div(v-bind:style='{width: currentProgress}')
button.button.is-light-blue.is-outlined(v-on:click='proceedToAdmin', v-bind:disabled='loading') Back
button.button.is-teal(v-on:click='proceedToFinal', v-if='!loading && !final.ok') Try Again
button.button.is-green(v-on:click='finish', v-if='loading || final.ok', v-bind:disabled='loading') Start
//- ==============================================
//- RESTART
//- ==============================================
template(v-else-if='state === "restart"')
.panel
h2.panel-title.is-featured
span Restarting...
i
.panel-content.is-text
p #[i.icon-loader.animated.rotateIn.infinite] Restarting Wiki.js in normal mode...
p You'll automatically be redirected to the homepage when ready. This usually takes about 30 seconds.
.panel-footer
button.button.is-green(disabled='disabled') Start
footer.footer
span
| Powered by
a(href='https://github.com/Requarks/wiki') Wiki.js
| .
block outside
+29
View File
@@ -0,0 +1,29 @@
doctype html
html(data-logic='error')
head
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
meta(name='theme-color', content='#009688')
meta(name='msapplication-TileColor', content='#009688')
meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
title= appconfig.title
// Favicon
each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
each favsize in [32, 96, 16]
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
link(rel='manifest', href='/manifest.json')
// JS / CSS
script(type='text/javascript', src='/js/bundle.min.js')
body(class='is-forbidden')
.container
a(href='/'): img(src='/favicons/android-icon-96x96.png')
h1 Forbidden
h2 Sorry, you don't have the necessary permissions to access this page.
a.button.is-amber.is-inverted(href='/') Go Home
a.button.is-amber.is-inverted(href='/login') Login as...
+29
View File
@@ -0,0 +1,29 @@
doctype html
html(data-logic='error')
head
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
meta(name='theme-color', content='#009688')
meta(name='msapplication-TileColor', content='#009688')
meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
title= appconfig.title
// Favicon
each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
each favsize in [32, 96, 16]
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
link(rel='manifest', href='/manifest.json')
// JS / CSS
script(type='text/javascript', src='/js/bundle.min.js')
body(class='is-notexist')
.container
a(href='/'): img(src='/favicons/android-icon-96x96.png')
h1= message
h2 Would you like to create this entry?
a.button.is-amber.is-inverted.is-featured(href='/create/' + newpath) Create
a.button.is-amber.is-inverted(href='/') Go Home
+32
View File
@@ -0,0 +1,32 @@
doctype html
html(data-logic='error')
head
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
meta(name='theme-color', content='#009688')
meta(name='msapplication-TileColor', content='#009688')
meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
title= appconfig.title
// Favicon
each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
each favsize in [32, 96, 16]
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
link(rel='manifest', href='/manifest.json')
// JS / CSS
script(type='text/javascript', src='/js/bundle.min.js')
body(class='is-error')
.container
a(href='/'): img(src='/favicons/android-icon-96x96.png')
h1= message
h2 Oops, something went wrong
a.button.is-amber.is-inverted.is-featured(href='/') Go Home
if error.stack
h3 Detailed debug trail:
pre: code #{error.stack}
+33
View File
@@ -0,0 +1,33 @@
doctype html
html
head
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
meta(name='theme-color', content='#009688')
meta(name='msapplication-TileColor', content='#009688')
meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
title= appconfig.title
// Favicon
each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
each favsize in [32, 96, 16]
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
link(rel='manifest', href='/manifest.json')
// JS / CSS
script(type='text/javascript', src='/js/bundle.min.js')
block head
body
#root.has-stickynav
include ./common/header.pug
include ./common/alerts.pug
main
block content
include ./common/footer.pug
block outside
+39
View File
@@ -0,0 +1,39 @@
.modal#modal-admin-users-create
.modal-background
.modal-container
.modal-content
header.is-blue
span Create / Authorize User
p.modal-notify(v-bind:class='{ "is-active": loading }'): i
section
label.label Email address:
p.control.is-fullwidth
input.input(type='text', placeholder='e.g. john.doe@company.com', v-model='email')
section
label.label Provider:
p.control.is-fullwidth
select(v-model='provider')
option(value='local') Local Database
if appconfig.auth.microsoft.enabled
option(value='windowslive') Microsoft Account
if appconfig.auth.google.enabled
option(value='google') Google ID
if appconfig.auth.facebook.enabled
option(value='facebook') Facebook
if appconfig.auth.github.enabled
option(value='github') GitHub
if appconfig.auth.slack.enabled
option(value='slack') Slack
section(v-if='provider=="local"')
label.label Password:
p.control.is-fullwidth
input.input(type='password', placeholder='', v-model='password')
section(v-if='provider=="local"')
label.label Full Name:
p.control.is-fullwidth
input.input(type='text', placeholder='e.g. John Doe', v-model='name')
footer
a.button.is-grey.is-outlined(v-on:click='cancel') Discard
a.button(v-on:click='create', v-if='provider=="local"', v-bind:disabled='loading', v-bind:class='{ "is-disabled": loading, "is-blue": !loading }') Create User
a.button(v-on:click='create', v-if='provider!="local"', v-bind:disabled='loading', v-bind:class='{ "is-disabled": loading, "is-blue": !loading }') Authorize User
+12
View File
@@ -0,0 +1,12 @@
.modal#modal-admin-users-delete
.modal-background
.modal-container
.modal-content
header.is-red
span Delete User Account?
p.modal-notify(v-bind:class='{ "is-active": loading }'): i
section
span Are you sure you want to delete this user account? This action cannot be undone!
footer
a.button.is-grey.is-outlined(v-on:click='cancel') Abort
a.button.is-red(v-on:click='deleteUser') Delete
+25
View File
@@ -0,0 +1,25 @@
.modal(v-bind:class='{ "is-active": upgradeModal.state }')
.modal-background
.modal-container
.modal-content
template(v-if='upgradeModal.step === "running"')
header.is-blue Install
section.modal-loading
i
span Wiki.js {{ upgradeModal.mode }} in progress...
em Please wait
template(v-if='upgradeModal.step === "error"')
header.is-red Installation Error
section.modal-loading
span {{ upgradeModal.error }}
footer
a.button.is-grey.is-outlined(v-on:click='upgradeCancel') Abort
a.button.is-deep-orange(v-on:click='upgradeStart') Try Again
template(v-if='upgradeModal.step === "confirm"')
header.is-deep-orange Are you sure?
section
label.label You are about to {{ upgradeModal.mode }} Wiki.js.
span.note You will not be able to access your wiki during the operation. Content will not be affected. However, it is your responsability to ensure you have a backup in the unexpected event content gets lost or corrupted.
footer
a.button.is-grey.is-outlined(v-on:click='upgradeCancel') Abort
a.button.is-deep-orange(v-on:click='upgradeStart') Start
+10
View File
@@ -0,0 +1,10 @@
.modal#modal-create-discard
.modal-background
.modal-container
.modal-content
header.is-orange Discard?
section
span Are you sure you want to leave this page and loose anything you wrote so far?
footer
a.button.is-grey.is-outlined.btn-create-discard Stay on page
a.button.is-orange(href='/') Discard
+14
View File
@@ -0,0 +1,14 @@
.modal#modal-create-prompt
.modal-background
.modal-container
.modal-content
header.is-light-blue Create New Document
section
label.label Enter the new document path:
p.control.is-fullwidth
input.input#txt-create-prompt(type='text', placeholder='page-name')
span.help.is-danger.is-hidden This document path is invalid!
footer
a.button.is-grey.is-outlined.btn-create-prompt Discard
a.button.is-light-blue.btn-create-go Create
+11
View File
@@ -0,0 +1,11 @@
.modal#modal-edit-discard
.modal-background
.modal-container
.modal-content
header.is-orange Discard?
section
span Are you sure you want to leave this page and loose any modifications?
footer
a.button.is-grey.is-outlined.btn-edit-discard Stay on page
a.button.is-orange(href='/' + pageData.meta.path) Discard
+23
View File
@@ -0,0 +1,23 @@
.modal#modal-editor-codeblock
.modal-background
.modal-container
.modal-content.is-expanded
header.is-green
span Insert Code Block
section.is-gapless
.columns.is-stretched
.column.is-one-quarter.modal-sidebar.is-green(style={'max-width':'350px'})
.model-sidebar-header Language
.model-sidebar-content
p.control.is-fullwidth
select(v-model='modeSelected')
option(v-for='mode in modes', v-bind:value='mode.name') {{ mode.caption }}
.column.ace-container
#codeblock-editor
footer
a.button.is-grey.is-outlined(v-on:click='cancel') Discard
a.button.is-green(v-on:click='insertCode') Insert Code Block
+80
View File
@@ -0,0 +1,80 @@
.modal#modal-editor-file
.modal-background
.modal-container
.modal-content.is-expanded
header.is-green
span Insert File
p.modal-notify(v-bind:class='{ "is-active": isLoading }')
span {{ isLoadingText }}
i
.modal-toolbar.is-green
a.button(v-on:click='newFolder')
i.fa.fa-folder
span New Folder
a.button#btn-editor-file-upload
i.fa.fa-upload
span Upload File
label
input(type='file', multiple)
section.is-gapless
.columns.is-stretched
.column.is-one-quarter.modal-sidebar.is-green(style={'max-width':'350px'})
.model-sidebar-header Folders
ul.model-sidebar-list
li(v-for='fld in folders')
a(v-on:click='selectFolder(fld)', v-bind:class='{ "is-active": currentFolder === fld }')
i.icon-folder2
span / {{ fld }}
.column.editor-modal-file-choices
figure(v-for='fl in files', v-bind:class='{ "is-active": currentFile === fl._id }', v-on:click='selectFile(fl._id)', v-bind:data-uid='fl._id')
i(class='icon-file')
span: strong {{ fl.filename }}
span {{ fl.mime }}
span {{ fl.filesize | filesize }}
em(v-show='files.length < 1')
i.icon-marquee-minus
| This folder is empty.
footer
a.button.is-grey.is-outlined(v-on:click='cancel') Discard
a.button.is-green(v-on:click='insertFileLink') Insert Link to File
.modal.is-superimposed(v-bind:class='{ "is-active": newFolderShow }')
.modal-background
.modal-container
.modal-content
header.is-light-blue New Folder
section
label.label Enter the new folder name:
p.control.is-fullwidth
input.input#txt-editor-file-newfoldername(type='text', placeholder='folder name', v-model='newFolderName', v-on:keyup.enter='newFolderCreate', v-on:keyup.esc='newFolderDiscard')
span.help.is-danger(v-show='newFolderError') This folder name is invalid!
footer
a.button.is-grey.is-outlined(v-on:click='newFolderDiscard') Discard
a.button.is-light-blue(v-on:click='newFolderCreate') Create
.modal.is-superimposed(v-bind:class='{ "is-active": renameFileShow }')
.modal-background
.modal-container
.modal-content
header.is-indigo Rename File
section
label.label Enter the new filename (without the extension) of the file:
p.control.is-fullwidth
input.input#txt-editor-file-rename(type='text', placeholder='filename', v-model='renameFileFilename')
span.help.is-danger.is-hidden This filename is invalid!
footer
a.button.is-grey.is-outlined(v-on:click='renameFileDiscard') Discard
a.button.is-light-blue(v-on:click='renameFileGo') Rename
.modal.is-superimposed(v-bind:class='{ "is-active": deleteFileShow }')
.modal-background
.modal-container
.modal-content
header.is-red Delete file?
section
span Are you sure you want to delete #[strong {{deleteFileFilename}}]?
footer
a.button.is-grey.is-outlined(v-on:click='deleteFileWarn(false)') Discard
a.button.is-red(v-on:click='deleteFileGo') Delete
+104
View File
@@ -0,0 +1,104 @@
.modal#modal-editor-image
.modal-background
.modal-container
.modal-content.is-expanded
header.is-green
span Insert Image
p.modal-notify(v-bind:class='{ "is-active": isLoading }')
span {{ isLoadingText }}
i
.modal-toolbar.is-green
a.button(v-on:click='newFolder')
i.fa.fa-folder
span New Folder
a.button#btn-editor-image-upload
i.fa.fa-upload
span Upload Image
label
input(type='file', multiple)
a.button(v-on:click='fetchFromUrl')
i.fa.fa-download
span Fetch from URL
section.is-gapless
.columns.is-stretched
.column.is-one-quarter.modal-sidebar.is-green(style={'max-width':'350px'})
.model-sidebar-header Folders
ul.model-sidebar-list
li(v-for='fld in folders')
a(v-on:click='selectFolder(fld)', v-bind:class='{ "is-active": currentFolder === fld }')
i.icon-folder2
span / {{ fld }}
.model-sidebar-header Alignment
.model-sidebar-content
p.control.is-fullwidth
select(v-model='currentAlign')
option(value='left') Left (default)
option(value='center') Centered
option(value='right') Right
option(value='logo') Page Logo
.column.editor-modal-image-choices
figure(v-for='img in images', v-bind:class='{ "is-active": currentImage === img._id }', v-on:click='selectImage(img._id)', v-bind:data-uid='img._id')
img(v-bind:src='"/uploads/t/" + img._id + ".png"')
span: strong {{ img.basename }}
span {{ img.filesize | filesize }}
em(v-show='images.length < 1')
i.icon-marquee-minus
| This folder is empty.
footer
a.button.is-grey.is-outlined(v-on:click='cancel') Discard
a.button.is-green(v-on:click='insertImage') Insert Image
.modal.is-superimposed(v-bind:class='{ "is-active": newFolderShow }')
.modal-background
.modal-container
.modal-content
header.is-light-blue New Folder
section
label.label Enter the new folder name:
p.control.is-fullwidth
input.input#txt-editor-image-newfoldername(type='text', placeholder='folder name', v-model='newFolderName', v-on:keyup.enter='newFolderCreate', v-on:keyup.esc='newFolderDiscard')
span.help.is-danger(v-show='newFolderError') This folder name is invalid!
footer
a.button.is-grey.is-outlined(v-on:click='newFolderDiscard') Discard
a.button.is-light-blue(v-on:click='newFolderCreate') Create
.modal.is-superimposed(v-bind:class='{ "is-active": fetchFromUrlShow }')
.modal-background
.modal-container
.modal-content
header.is-light-blue Fetch Image from URL
section
label.label Enter full URL path to the image:
p.control.is-fullwidth
input.input#txt-editor-image-fetchurl(type='text', placeholder='http://www.example.com/some-image.png', v-model='fetchFromUrlURL')
span.help.is-danger.is-hidden This URL path is invalid!
footer
a.button.is-grey.is-outlined(v-on:click='fetchFromUrlDiscard') Discard
a.button.is-light-blue(v-on:click='fetchFromUrlGo') Fetch
.modal.is-superimposed(v-bind:class='{ "is-active": renameImageShow }')
.modal-background
.modal-container
.modal-content
header.is-indigo Rename Image
section
label.label Enter the new filename (without the extension) of the image:
p.control.is-fullwidth
input.input#txt-editor-image-rename(type='text', placeholder='filename', v-model='renameImageFilename')
span.help.is-danger.is-hidden This filename is invalid!
footer
a.button.is-grey.is-outlined(v-on:click='renameImageDiscard') Discard
a.button.is-light-blue(v-on:click='renameImageGo') Rename
.modal.is-superimposed(v-bind:class='{ "is-active": deleteImageShow }')
.modal-background
.modal-container
.modal-content
header.is-red Delete image?
section
span Are you sure you want to delete #[strong {{deleteImageFilename}}]?
footer
a.button.is-grey.is-outlined(v-on:click='deleteImageWarn(false)') Discard
a.button.is-red(v-on:click='deleteImageGo') Delete
+33
View File
@@ -0,0 +1,33 @@
//.modallayer#modal-editor-link
.modallayer-content
.tabs.is-boxed
ul
li
a
span.icon.is-small
i.fa.fa-file-text-o
span Internal Document Link
li.is-active
a
span.icon.is-small
i.fa.fa-external-link
span External Link
.columns.is-hidden
.column
label.label Text to display
p.control
input.input(type='text', placeholder='Text input')
.column
label.label Link
p.control
input.input(type='text', placeholder='http://')
.columns
.column
label.label Text to display
p.control
input.input(type='text', placeholder='Text input')
.column
label.label Link
p.control
input.input(type='text', placeholder='http://')
+28
View File
@@ -0,0 +1,28 @@
.modal#modal-editor-video
.modal-background
.modal-container
.modal-content
header.is-green Insert Video Player
section
label.label Enter the link to the video to be embedded:
p.control.is-fullwidth
input.input(type='text', placeholder='https://www.youtube.com/watch?v=xxxxxxxxxxx', v-model='link')
span.help.is-red.is-hidden This URL is invalid or not supported!
.note The following are supported:
ul
li
i.icon-youtube-play
span Youtube
li
i.icon-vimeo
span Vimeo
li
i.icon-film
span Dailymotion
li
i.icon-video
span Any standard MP4 file
footer
a.button.is-grey.is-outlined(v-on:click='cancel') Discard
a.button.is-green(v-on:click='insertVideo') Insert Video
+15
View File
@@ -0,0 +1,15 @@
.modal#modal-move-prompt
.modal-background
.modal-container
.modal-content
header.is-indigo Move document
section
label.label Enter the new document path:
p.control.is-fullwidth
input.input#txt-move-prompt(type='text', placeholder='page-name')
span.help.is-red.is-hidden This document path is invalid or not allowed!
span.note Note that moving or renaming documents can lead to broken links. Make sure to edit any page that links to this document afterwards!
footer
a.button.is-grey.is-outlined.btn-move-prompt Discard
a.button.is-indigo.btn-move-go Move
+57
View File
@@ -0,0 +1,57 @@
extends ../../layout.pug
block rootNavCenter
h2.nav-item Account
block rootNavRight
i.nav-item#notifload
.nav-item
a.button.btn-edit-discard(href='/')
i.icon-home
span Home
block content
#page-type-account
.container.is-fluid
.columns.is-gapless
.column.is-narrow.is-hidden-touch.sidebar
aside
.sidebar-label
span Navigation
ul.sidebar-menu
li
a(href='/')
i.icon-home
span Home
aside
.sidebar-label
span Account
ul.sidebar-menu
li
a(href='/admin/profile')
i.icon-user
span My Profile
li
a(href='/admin/stats')
i.icon-bar-graph-2
span Stats
if rights.manage
li
a(href='/admin/users')
i.icon-users
span Users
li
a(href='/admin/settings')
i.icon-cog
span System Settings
li
a(href='/logout')
i.icon-delete2
span Logout
.column
block adminContent
+53
View File
@@ -0,0 +1,53 @@
extends ./_layout.pug
block adminContent
#page-type-admin-profile
.hero
h1.title#title My Profile
h2.subtitle Profile and authentication info
.form-sections
.columns.is-gapless
.column.is-two-thirds
section
label.label Email
p.control.is-fullwidth
input.input(type='text', placeholder='Email', value=user.email, disabled)
if user.provider === 'local'
section
label.label Password
p.control.is-fullwidth
input.input(type='password', placeholder='Password', value='********', v-model='password')
section
label.label Verify Password
p.control.is-fullwidth
input.input(type='password', placeholder='Password', value='********', v-model='passwordVerify')
section
label.label Display Name
p.control.is-fullwidth
input.input(type='text', placeholder='John Smith', v-model='name')
section
button.button.is-green(v-on:click='saveUser')
i.icon-check
span Save Changes
.column
.panel-aside
label.label Provider
p.control.account-profile-provider
case user.provider
when 'local': i.icon-server
when 'windowslive': i.icon-windows2.is-blue
when 'azure': i.icon-windows2.is-blue
when 'google': i.icon-google.is-blue
when 'facebook': i.icon-facebook.is-indigo
when 'github': i.icon-github.is-grey
when 'slack': i.icon-slack.is-purple
when 'ldap': i.icon-arrow-repeat-outline
default: i.icon-warning
= t('auth:providers.' + user.provider)
label.label Member since
p.control= userMoment(user.createdAt).format('LL')
label.label Last Profile Update
p.control= userMoment(user.updatedAt).format('LL')
script(type='text/javascript').
var usrDataName = "!{user.name}";
+39
View File
@@ -0,0 +1,39 @@
extends ./_layout.pug
block adminContent
#page-type-admin-settings
.hero
h1.title#title System Settings
h2.subtitle Manage site configuration
.form-sections
section
label.label System Version
.section-block
p Current Version: #[strong= sysversion.current]
if sysversion.latest
p Latest Version: #[strong= sysversion.latest] #[em (Published #{userMoment(sysversion.latestPublishedAt).fromNow()})]
p
if sysversion.current !== sysversion.latest
button.button.is-deep-orange(v-on:click='upgrade') Upgrade
else
button.button.is-disabled Upgrade
button.button.is-deep-orange.is-outlined(v-on:click='reinstall') Re-install current version
else
p: em Unable to query latest version. Try again later.
section
label.label Administrative Tools
.section-block
h6 Flush cache and rebuild indexes:
p.is-small If content or search results seems out-of-date or do not include latest content, flushing the cache can help resolve these issues.
p: button.button.is-teal.is-outlined(v-on:click='flushcache') Flush and Rebuild
h6 Reset the root administrator and guest accounts to defaults:
p.is-small
| The root administrator account will be reset to the email address in the configuration file and the password will be reinitialized to #[strong admin123].
br
| The guest account will be recreated with its access rights set to defaults.
p: button.button.is-teal.is-outlined(v-on:click='resetaccounts') Reset System Accounts
h6 Flush all active user sessions:
p.is-small All users will be logged out and forced to login again. Your current session will also be affected!
p: button.button.is-teal.is-outlined(v-on:click='flushsessions') Flush Sessions
include ../../modals/admin-upgrade.pug
+14
View File
@@ -0,0 +1,14 @@
extends ./_layout.pug
block adminContent
.hero
h1.title#title Stats
h2.subtitle General site-wide statistics
.form-sections
section
label.label Entries
p.control= totalEntries
label.label Uploads
p.control= totalUploads
label.label Users
p.control= totalUsers
+138
View File
@@ -0,0 +1,138 @@
extends ./_layout.pug
block rootNavRight
i.nav-item#notifload
.nav-item
a.button(href='/admin/users')
i.icon-reply
span Return to Users
block adminContent
#page-type-admin-users-edit
.hero
h1.title#title Edit User
h2.subtitle= usr.email
table.table
thead
tr
th Unique ID
th Provider
th Created On
th Updated On
tbody
tr
td.is-centered= usr._id
td.is-centered.has-icons
case usr.provider
when 'local'
i.icon-server.is-deep-orange
| Local Database
when 'windowslive'
i.icon-windows2.is-blue
| Microsoft Account
when 'azure'
i.icon-windows2.is-blue
| Azure Active Directory
when 'google'
i.icon-google.is-blue
| Google ID
when 'facebook'
i.icon-facebook.is-indigo
| Facebook
when 'github'
i.icon-github.is-blue-grey
| GitHub
when 'slack'
i.icon-slack.is-purple
| Slack
when 'ldap'
i.icon-arrow-repeat-outline
| LDAP / Active Directory
default: i.icon-warning
td.is-centered= userMoment(usr.createdAt).format('lll')
td.is-centered= userMoment(usr.updatedAt).format('lll')
.form-sections
section
label.label Email Address
p.control.is-fullwidth
input.input(type='text', placeholder='john.smith@example.com', v-model='email', disabled=!usrOpts.canChangeEmail)
section
label.label Display Name
p.control.is-fullwidth
input.input(type='text', placeholder='John Smith', v-model='name', disabled=!usrOpts.canChangeName)
if usrOpts.canChangePassword
section
label.label Password
p.control.is-fullwidth
input.input(type='password', placeholder='Password', v-model='password', value='********')
section
label.label Access Rights
table.table
thead.is-teal
tr
th
th(style={width: '200px'}) Permission(s)
th Path
th(style={width: '150px'}) Access
th(style={width: '50px'})
tbody
tr(v-for='(right, idx) in rights', v-cloak)
td.is-icon
i.icon-marquee-plus.is-green(v-if='right.deny === false || right.deny === "false"')
i.icon-marquee-minus.is-red(v-if='right.deny === true || right.deny === "true"')
td
p.control.is-fullwidth
select(v-model='right.role')
option(value='write') Read and Write
option(value='read') Read Only
td
.columns
.column.is-narrow
p.control
select(v-model='right.exact')
option(value='false') Path starts with:
option(value='true') Path match exactly:
.column
p.control.is-fullwidth
input.input(type='text', placeholder='/', v-model='right.path')
td
p.control.is-fullwidth
select(v-model='right.deny')
option(value='false') Allow
option(value='true') Deny
td.is-centered.has-action-icons
i.icon-delete.is-red(v-on:click='removeRightsRow(idx)')
tr(v-if='rights.length < 1', v-cloak)
td.is-icon
td.is-centered(colspan='3'): em No additional access rights
td.is-centered.has-action-icons
.table-actions
button.button.is-blue(v-on:click='addRightsRow')
i.icon-plus
span Add New Row
section
label.label Role Override
p.control.is-fullwidth
select(v-model='roleoverride', disabled=!usrOpts.canChangeRole)
option(value='none') None
option(value='admin') Global Administrator
.columns.is-gapless
.column
section
button.button.is-green(v-on:click='saveUser')
i.icon-check
span Save Changes
a.button.button.is-grey.is-outlined(href='/admin/users')
i.icon-cancel
span Discard
.column.is-narrow
section
if usrOpts.canBeDeleted
button.button.is-red.btn-deluser-prompt
i.icon-trash2
span Delete Account
include ../../modals/admin-deleteuser.pug
script(type='text/javascript').
var usrData = !{JSON.stringify(usr)};
+62
View File
@@ -0,0 +1,62 @@
extends ./_layout.pug
block rootNavRight
i.nav-item#notifload
.nav-item
a.button.btn-create-prompt
i.icon-plus
span Create / Authorize User
block adminContent
#page-type-admin-users
.hero
h1.title#title Users
h2.subtitle Manage users and access rights
table.table
thead
tr
th
th Name
th Email
th Provider
th Created On
th Updated On
tbody
each usr in usrs
tr
td.is-icon
i.icon-user.is-grey
td
a(href='/admin/users/' + usr._id)= usr.name
td= usr.email
td.is-centered.has-icons
case usr.provider
when 'local'
i.icon-server.is-deep-orange
| Local Database
when 'windowslive'
i.icon-windows2.is-blue
| Microsoft Account
when 'azure'
i.icon-windows2.is-blue
| Azure Active Directory
when 'google'
i.icon-google.is-blue
| Google ID
when 'facebook'
i.icon-facebook.is-purple
| Facebook
when 'github'
i.icon-github.is-blue-grey
| GitHub
when 'slack'
i.icon-slack.is-purple
| Slack
when 'ldap'
i.icon-arrow-repeat-outline
| LDAP / Active Directory
default: i.icon-warning
td.is-centered= userMoment(usr.createdAt).format('lll')
td.is-centered= userMoment(usr.updatedAt).format('lll')
include ../../modals/admin-createuser.pug
+40
View File
@@ -0,0 +1,40 @@
extends ../layout.pug
block rootNavRight
i.nav-item#notifload
block content
#page-type-all
.container.is-fluid.has-collapsable-nav
.sidebar.is-collapsed
aside
.sidebar-label
span NAV
ul.sidebar-menu
li
a(href='/')
i.icon-home
span Home
if !isGuest
li
a(href='/admin')
i.icon-head
span Account
else
li
a(href='/login')
i.icon-unlock
span Login
ul.collapsable-nav(v-for='treeItem in tree', :class='{ "has-children": treeItem.hasChildren }', v-cloak)
li(v-for='page in treeItem.pages', :class='{ "is-active": page.isActive }')
a(v-on:click='mainAction(page)')
template(v-if='page._id !== "home"')
i(:class='{ "icon-folder2": page.isDirectory, "icon-file-text-o": !page.isDirectory }')
span {{ page.title }}
template(v-else)
i.icon-home
span Home
a.is-pagelink(v-if='page.isDirectory && page.isEntry', v-on:click='goto(page._id)')
i.icon-file-text-o
i.icon-arrow-right2
+32
View File
@@ -0,0 +1,32 @@
extends ../layout.pug
block rootNavCenter
h2.nav-item Create New Document
block rootNavRight
i.nav-item#notifload
span.nav-item
a.button.is-outlined.btn-create-discard
i.icon-cross
span Discard
a.button.btn-create-save
i.icon-check
span Save Document
block content
#page-type-create(data-entrypath=pageData.meta.path)
.editor-area
textarea#mk-editor= pageData.markdown
include ../modals/create-discard.pug
include ../modals/editor-link.pug
include ../modals/editor-image.pug
include ../modals/editor-file.pug
include ../modals/editor-video.pug
include ../modals/editor-codeblock.pug
block outside
#page-loader
i
span Loading editor...
+32
View File
@@ -0,0 +1,32 @@
extends ../layout.pug
block rootNavCenter
h2.nav-item= pageData.meta.title
block rootNavRight
i.nav-item#notifload
span.nav-item
a.button.is-outlined.btn-edit-discard
i.icon-cross
span Discard
a.button.btn-edit-save
i.icon-check
span Save Changes
block content
#page-type-edit(data-entrypath=pageData.meta.path)
.editor-area
textarea#mk-editor= pageData.markdown
include ../modals/edit-discard.pug
include ../modals/editor-link.pug
include ../modals/editor-image.pug
include ../modals/editor-file.pug
include ../modals/editor-video.pug
include ../modals/editor-codeblock.pug
block outside
#page-loader
i
span Loading editor...
+36
View File
@@ -0,0 +1,36 @@
extends ../layout.pug
block rootNavCenter
h2.nav-item= pageData.meta.title
block rootNavRight
i.nav-item#notifload
span.nav-item
if rights.write
a.button.is-outlined.btn-move-prompt.is-hidden
i.icon-shuffle
span Move
a.button.is-outlined(href='/' + pageData.meta.path)
i.icon-loader
span Normal View
if rights.write
a.button.is-orange(href='/edit/' + pageData.meta.path)
i.fa.fa-edit
span Edit
a.button.is-blue.btn-create-prompt
i.fa.fa-plus
span Create
block content
#page-type-source(data-entrypath=pageData.meta.path)
.ace-container
#source-display= pageData.markdown
include ../modals/create.pug
include ../modals/move.pug
block outside
#page-loader
i
span Loading source...
+81
View File
@@ -0,0 +1,81 @@
extends ../layout.pug
mixin tocMenu(ti)
each node in ti
li
a(href='#' + node.anchor, title=node.content)= node.content
if node.nodes.length > 0
ul
+tocMenu(node.nodes)
block rootNavRight
i.nav-item#notifload
.nav-item
if rights.write
a.button.is-outlined.btn-move-prompt.is-hidden
i.icon-shuffle
span Move
a.button.is-outlined(href='/source/' + pageData.meta.path)
i.icon-loader
span Source
if rights.write
a.button(href='/edit/' + pageData.meta.path)
i.icon-document-text
span Edit
a.button.btn-create-prompt
i.icon-plus
span Create
block content
#page-type-view.page-type-container(data-entrypath=pageData.meta.path)
.container.is-fluid.has-mkcontent
.columns.is-gapless
.column.is-narrow.is-hidden-touch.sidebar
aside
.sidebar-label
span Navigation
ul.sidebar-menu
li
a(href='/')
i.icon-home
span Home
li
a(href='/all')
i.icon-paper
span All Pages
if pageData.parent
li
a(href='/' + pageData.parent.path)
i.icon-reply
span= pageData.parent.title
if !isGuest
li
a(href='/admin')
i.icon-head
span Account
else
li
a(href='/login')
i.icon-unlock
span Login
aside.stickyscroll(data-margin-top=15)
.sidebar-label
span Page Contents
ul.sidebar-menu
li: a(href='#root', title='Top of Page') Top of Page
+tocMenu(pageData.tree)
.column
.hero
h1.title#title= pageData.meta.title
if pageData.meta.subtitle
h2.subtitle= pageData.meta.subtitle
.content.mkcontent
!= pageData.html
include ../modals/create.pug
include ../modals/move.pug
+16
View File
@@ -0,0 +1,16 @@
extends ../layout.pug
block rootNavCenter
block rootNavRight
i.nav-item#notifload
block content
#page-type-welcome
.container
.welcome
img(src='/images/logo.png', alt='Wiki.js')
h1 Welcome to your wiki!
h2 Let's get started and create the home page.
a.button.is-indigo(href='/create/home') Create Home Page