mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-12-29 12:23:37 +00:00
da4665c759
Do Not Track (DNT) is an old web standard in order to notify pages that the user doesn't want to be tracked. Even while a lot of pages either ignore this header or even worse, use it for tracking purposes, the orignal intention of this header is good and should be adopted. This patch implements a respect of the DNT header by no longer including the optional Google Analytics and disqus integrations when sending a DNT header. This should reduce outside resource usage and help to stay more private. This should later-on extended towards other document content (i.e. iframe based content). The reason to not change the CDN handling is that CDNs will be deprecated with next release and removed in long term. Signed-off-by: Sheogorath <sheogorath@shivering-isles.com>
630 lines
19 KiB
JavaScript
630 lines
19 KiB
JavaScript
'use strict'
|
|
// response
|
|
// external modules
|
|
var fs = require('fs')
|
|
var path = require('path')
|
|
var markdownpdf = require('markdown-pdf')
|
|
var shortId = require('shortid')
|
|
var querystring = require('querystring')
|
|
var request = require('request')
|
|
var moment = require('moment')
|
|
|
|
// core
|
|
var config = require('./config')
|
|
var logger = require('./logger')
|
|
var models = require('./models')
|
|
var utils = require('./utils')
|
|
|
|
// public
|
|
var response = {
|
|
errorForbidden: function (res) {
|
|
const { req } = res
|
|
if (req.user) {
|
|
responseError(res, '403', 'Forbidden', 'oh no.')
|
|
} else {
|
|
req.flash('error', 'You are not allowed to access this page. Maybe try logging in?')
|
|
res.redirect(config.serverURL + '/')
|
|
}
|
|
},
|
|
errorNotFound: function (res) {
|
|
responseError(res, '404', 'Not Found', 'oops.')
|
|
},
|
|
errorBadRequest: function (res) {
|
|
responseError(res, '400', 'Bad Request', 'something not right.')
|
|
},
|
|
errorTooLong: function (res) {
|
|
responseError(res, '413', 'Payload Too Large', 'Shorten your note!')
|
|
},
|
|
errorInternalError: function (res) {
|
|
responseError(res, '500', 'Internal Error', 'wtf.')
|
|
},
|
|
errorServiceUnavailable: function (res) {
|
|
res.status(503).send("I'm busy right now, try again later.")
|
|
},
|
|
newNote: newNote,
|
|
showNote: showNote,
|
|
showPublishNote: showPublishNote,
|
|
showPublishSlide: showPublishSlide,
|
|
showIndex: showIndex,
|
|
noteActions: noteActions,
|
|
publishNoteActions: publishNoteActions,
|
|
publishSlideActions: publishSlideActions,
|
|
githubActions: githubActions,
|
|
gitlabActions: gitlabActions
|
|
}
|
|
|
|
function responseError (res, code, detail, msg) {
|
|
res.status(code).render('error.ejs', {
|
|
title: code + ' ' + detail + ' ' + msg,
|
|
code: code,
|
|
detail: detail,
|
|
msg: msg
|
|
})
|
|
}
|
|
|
|
function showIndex (req, res, next) {
|
|
var authStatus = req.isAuthenticated()
|
|
var deleteToken = ''
|
|
|
|
var data = {
|
|
signin: authStatus,
|
|
infoMessage: req.flash('info'),
|
|
errorMessage: req.flash('error'),
|
|
privacyStatement: fs.existsSync(path.join(config.docsPath, 'privacy.md')),
|
|
termsOfUse: fs.existsSync(path.join(config.docsPath, 'terms-of-use.md')),
|
|
deleteToken: deleteToken
|
|
}
|
|
|
|
if (authStatus) {
|
|
models.User.findOne({
|
|
where: {
|
|
id: req.user.id
|
|
}
|
|
}).then(function (user) {
|
|
if (user) {
|
|
data.deleteToken = user.deleteToken
|
|
res.render('index.ejs', data)
|
|
}
|
|
})
|
|
} else {
|
|
res.render('index.ejs', data)
|
|
}
|
|
}
|
|
|
|
function responseCodiMD (res, note) {
|
|
var body = note.content
|
|
var extracted = models.Note.extractMeta(body)
|
|
var meta = models.Note.parseMeta(extracted.meta)
|
|
var title = models.Note.decodeTitle(note.title)
|
|
title = models.Note.generateWebTitle(meta.title || title)
|
|
res.set({
|
|
'Cache-Control': 'private', // only cache by client
|
|
'X-Robots-Tag': 'noindex, nofollow' // prevent crawling
|
|
})
|
|
res.render('codimd.ejs', {
|
|
title: title
|
|
})
|
|
}
|
|
|
|
function newNote (req, res, next) {
|
|
var owner = null
|
|
var body = ''
|
|
if (req.body && req.body.length > config.documentMaxLength) {
|
|
return response.errorTooLong(res)
|
|
} else if (req.body) {
|
|
body = req.body
|
|
}
|
|
body = body.replace(/[\r]/g, '')
|
|
if (req.isAuthenticated()) {
|
|
owner = req.user.id
|
|
} else if (!config.allowAnonymous) {
|
|
return response.errorForbidden(res)
|
|
}
|
|
models.Note.create({
|
|
ownerId: owner,
|
|
alias: req.alias ? req.alias : null,
|
|
content: body
|
|
}).then(function (note) {
|
|
return res.redirect(config.serverURL + '/' + models.Note.encodeNoteId(note.id))
|
|
}).catch(function (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
})
|
|
}
|
|
|
|
function checkViewPermission (req, note) {
|
|
if (note.permission === 'private') {
|
|
if (!req.isAuthenticated() || note.ownerId !== req.user.id) { return false } else { return true }
|
|
} else if (note.permission === 'limited' || note.permission === 'protected') {
|
|
if (!req.isAuthenticated()) { return false } else { return true }
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
function findNote (req, res, callback, include) {
|
|
var noteId = req.params.noteId
|
|
var id = req.params.noteId || req.params.shortid
|
|
models.Note.parseNoteId(id, function (err, _id) {
|
|
if (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
}
|
|
models.Note.findOne({
|
|
where: {
|
|
id: _id
|
|
},
|
|
include: include || null
|
|
}).then(function (note) {
|
|
if (!note) {
|
|
if (config.allowFreeURL && noteId && !config.forbiddenNoteIDs.includes(noteId)) {
|
|
req.alias = noteId
|
|
return newNote(req, res)
|
|
} else {
|
|
return response.errorNotFound(res)
|
|
}
|
|
}
|
|
if (!checkViewPermission(req, note)) {
|
|
return response.errorForbidden(res)
|
|
} else {
|
|
return callback(note)
|
|
}
|
|
}).catch(function (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
})
|
|
})
|
|
}
|
|
|
|
function showNote (req, res, next) {
|
|
findNote(req, res, function (note) {
|
|
// force to use note id
|
|
var noteId = req.params.noteId
|
|
var id = models.Note.encodeNoteId(note.id)
|
|
if ((note.alias && noteId !== note.alias) || (!note.alias && noteId !== id)) { return res.redirect(config.serverURL + '/' + (note.alias || id)) }
|
|
return responseCodiMD(res, note)
|
|
})
|
|
}
|
|
|
|
function showPublishNote (req, res, next) {
|
|
var include = [{
|
|
model: models.User,
|
|
as: 'owner'
|
|
}, {
|
|
model: models.User,
|
|
as: 'lastchangeuser'
|
|
}]
|
|
findNote(req, res, function (note) {
|
|
// force to use short id
|
|
var shortid = req.params.shortid
|
|
if ((note.alias && shortid !== note.alias) || (!note.alias && shortid !== note.shortid)) {
|
|
return res.redirect(config.serverURL + '/s/' + (note.alias || note.shortid))
|
|
}
|
|
note.increment('viewcount').then(function (note) {
|
|
if (!note) {
|
|
return response.errorNotFound(res)
|
|
}
|
|
var body = note.content
|
|
var extracted = models.Note.extractMeta(body)
|
|
var markdown = extracted.markdown
|
|
var meta = models.Note.parseMeta(extracted.meta)
|
|
var createtime = note.createdAt
|
|
var updatetime = note.lastchangeAt
|
|
var title = models.Note.decodeTitle(note.title)
|
|
title = models.Note.generateWebTitle(meta.title || title)
|
|
var data = {
|
|
title: title,
|
|
description: meta.description || (markdown ? models.Note.generateDescription(markdown) : null),
|
|
viewcount: note.viewcount,
|
|
createtime: createtime,
|
|
updatetime: updatetime,
|
|
body: body,
|
|
owner: note.owner ? note.owner.id : null,
|
|
ownerprofile: note.owner ? models.User.getProfile(note.owner) : null,
|
|
lastchangeuser: note.lastchangeuser ? note.lastchangeuser.id : null,
|
|
lastchangeuserprofile: note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null,
|
|
robots: meta.robots || false, // default allow robots
|
|
GA: meta.GA,
|
|
disqus: meta.disqus,
|
|
cspNonce: res.locals.nonce,
|
|
dnt: req.headers.dnt
|
|
}
|
|
return renderPublish(data, res)
|
|
}).catch(function (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
})
|
|
}, include)
|
|
}
|
|
|
|
function renderPublish (data, res) {
|
|
res.set({
|
|
'Cache-Control': 'private' // only cache by client
|
|
})
|
|
res.render('pretty.ejs', data)
|
|
}
|
|
|
|
function actionPublish (req, res, note) {
|
|
res.redirect(config.serverURL + '/s/' + (note.alias || note.shortid))
|
|
}
|
|
|
|
function actionSlide (req, res, note) {
|
|
res.redirect(config.serverURL + '/p/' + (note.alias || note.shortid))
|
|
}
|
|
|
|
function actionDownload (req, res, note) {
|
|
var body = note.content
|
|
var title = models.Note.decodeTitle(note.title)
|
|
var filename = title
|
|
filename = encodeURIComponent(filename)
|
|
res.set({
|
|
'Access-Control-Allow-Origin': '*', // allow CORS as API
|
|
'Access-Control-Allow-Headers': 'Range',
|
|
'Access-Control-Expose-Headers': 'Cache-Control, Content-Encoding, Content-Range',
|
|
'Content-Type': 'text/markdown; charset=UTF-8',
|
|
'Cache-Control': 'private',
|
|
'Content-disposition': 'attachment; filename=' + filename + '.md',
|
|
'X-Robots-Tag': 'noindex, nofollow' // prevent crawling
|
|
})
|
|
res.send(body)
|
|
}
|
|
|
|
function actionInfo (req, res, note) {
|
|
var body = note.content
|
|
var extracted = models.Note.extractMeta(body)
|
|
var markdown = extracted.markdown
|
|
var meta = models.Note.parseMeta(extracted.meta)
|
|
var createtime = note.createdAt
|
|
var updatetime = note.lastchangeAt
|
|
var title = models.Note.decodeTitle(note.title)
|
|
var data = {
|
|
title: meta.title || title,
|
|
description: meta.description || (markdown ? models.Note.generateDescription(markdown) : null),
|
|
viewcount: note.viewcount,
|
|
createtime: createtime,
|
|
updatetime: updatetime
|
|
}
|
|
res.set({
|
|
'Access-Control-Allow-Origin': '*', // allow CORS as API
|
|
'Access-Control-Allow-Headers': 'Range',
|
|
'Access-Control-Expose-Headers': 'Cache-Control, Content-Encoding, Content-Range',
|
|
'Cache-Control': 'private', // only cache by client
|
|
'X-Robots-Tag': 'noindex, nofollow' // prevent crawling
|
|
})
|
|
res.send(data)
|
|
}
|
|
|
|
function actionPDF (req, res, note) {
|
|
var url = config.serverURL || 'http://' + req.get('host')
|
|
var body = note.content
|
|
var extracted = models.Note.extractMeta(body)
|
|
var content = extracted.markdown
|
|
var title = models.Note.decodeTitle(note.title)
|
|
|
|
if (!fs.existsSync(config.tmpPath)) {
|
|
fs.mkdirSync(config.tmpPath)
|
|
}
|
|
var path = config.tmpPath + '/' + Date.now() + '.pdf'
|
|
content = content.replace(/\]\(\//g, '](' + url + '/')
|
|
markdownpdf().from.string(content).to(path, function () {
|
|
if (!fs.existsSync(path)) {
|
|
logger.error('PDF seems to not be generated as expected. File doesn\'t exist: ' + path)
|
|
return response.errorInternalError(res)
|
|
}
|
|
var stream = fs.createReadStream(path)
|
|
var filename = title
|
|
// Be careful of special characters
|
|
filename = encodeURIComponent(filename)
|
|
// Ideally this should strip them
|
|
res.setHeader('Content-disposition', 'attachment; filename="' + filename + '.pdf"')
|
|
res.setHeader('Cache-Control', 'private')
|
|
res.setHeader('Content-Type', 'application/pdf; charset=UTF-8')
|
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow') // prevent crawling
|
|
stream.pipe(res)
|
|
fs.unlinkSync(path)
|
|
})
|
|
}
|
|
|
|
function actionGist (req, res, note) {
|
|
var data = {
|
|
client_id: config.github.clientID,
|
|
redirect_uri: config.serverURL + '/auth/github/callback/' + models.Note.encodeNoteId(note.id) + '/gist',
|
|
scope: 'gist',
|
|
state: shortId.generate()
|
|
}
|
|
var query = querystring.stringify(data)
|
|
res.redirect('https://github.com/login/oauth/authorize?' + query)
|
|
}
|
|
|
|
function actionRevision (req, res, note) {
|
|
var actionId = req.params.actionId
|
|
if (actionId) {
|
|
var time = moment(parseInt(actionId))
|
|
if (time.isValid()) {
|
|
models.Revision.getPatchedNoteRevisionByTime(note, time, function (err, content) {
|
|
if (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
}
|
|
if (!content) {
|
|
return response.errorNotFound(res)
|
|
}
|
|
res.set({
|
|
'Access-Control-Allow-Origin': '*', // allow CORS as API
|
|
'Access-Control-Allow-Headers': 'Range',
|
|
'Access-Control-Expose-Headers': 'Cache-Control, Content-Encoding, Content-Range',
|
|
'Cache-Control': 'private', // only cache by client
|
|
'X-Robots-Tag': 'noindex, nofollow' // prevent crawling
|
|
})
|
|
res.send(content)
|
|
})
|
|
} else {
|
|
return response.errorNotFound(res)
|
|
}
|
|
} else {
|
|
models.Revision.getNoteRevisions(note, function (err, data) {
|
|
if (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
}
|
|
var out = {
|
|
revision: data
|
|
}
|
|
res.set({
|
|
'Access-Control-Allow-Origin': '*', // allow CORS as API
|
|
'Access-Control-Allow-Headers': 'Range',
|
|
'Access-Control-Expose-Headers': 'Cache-Control, Content-Encoding, Content-Range',
|
|
'Cache-Control': 'private', // only cache by client
|
|
'X-Robots-Tag': 'noindex, nofollow' // prevent crawling
|
|
})
|
|
res.send(out)
|
|
})
|
|
}
|
|
}
|
|
|
|
function noteActions (req, res, next) {
|
|
var noteId = req.params.noteId
|
|
findNote(req, res, function (note) {
|
|
var action = req.params.action
|
|
switch (action) {
|
|
case 'publish':
|
|
case 'pretty': // pretty deprecated
|
|
actionPublish(req, res, note)
|
|
break
|
|
case 'slide':
|
|
actionSlide(req, res, note)
|
|
break
|
|
case 'download':
|
|
actionDownload(req, res, note)
|
|
break
|
|
case 'info':
|
|
actionInfo(req, res, note)
|
|
break
|
|
case 'pdf':
|
|
if (config.allowPDFExport) {
|
|
actionPDF(req, res, note)
|
|
} else {
|
|
logger.error('PDF export failed: Disabled by config. Set "allowPDFExport: true" to enable. Check the documentation for details')
|
|
response.errorForbidden(res)
|
|
}
|
|
break
|
|
case 'gist':
|
|
actionGist(req, res, note)
|
|
break
|
|
case 'revision':
|
|
actionRevision(req, res, note)
|
|
break
|
|
default:
|
|
return res.redirect(config.serverURL + '/' + noteId)
|
|
}
|
|
})
|
|
}
|
|
|
|
function publishNoteActions (req, res, next) {
|
|
findNote(req, res, function (note) {
|
|
var action = req.params.action
|
|
switch (action) {
|
|
case 'download':
|
|
actionDownload(req, res, note)
|
|
break
|
|
case 'edit':
|
|
res.redirect(config.serverURL + '/' + (note.alias ? note.alias : models.Note.encodeNoteId(note.id)) + '?both')
|
|
break
|
|
default:
|
|
res.redirect(config.serverURL + '/s/' + note.shortid)
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
function publishSlideActions (req, res, next) {
|
|
findNote(req, res, function (note) {
|
|
var action = req.params.action
|
|
switch (action) {
|
|
case 'edit':
|
|
res.redirect(config.serverURL + '/' + (note.alias ? note.alias : models.Note.encodeNoteId(note.id)) + '?both')
|
|
break
|
|
default:
|
|
res.redirect(config.serverURL + '/p/' + note.shortid)
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
function githubActions (req, res, next) {
|
|
var noteId = req.params.noteId
|
|
findNote(req, res, function (note) {
|
|
var action = req.params.action
|
|
switch (action) {
|
|
case 'gist':
|
|
githubActionGist(req, res, note)
|
|
break
|
|
default:
|
|
res.redirect(config.serverURL + '/' + noteId)
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
function githubActionGist (req, res, note) {
|
|
var code = req.query.code
|
|
var state = req.query.state
|
|
if (!code || !state) {
|
|
return response.errorForbidden(res)
|
|
} else {
|
|
var data = {
|
|
client_id: config.github.clientID,
|
|
client_secret: config.github.clientSecret,
|
|
code: code,
|
|
state: state
|
|
}
|
|
var authUrl = 'https://github.com/login/oauth/access_token'
|
|
request({
|
|
url: authUrl,
|
|
method: 'POST',
|
|
json: data
|
|
}, function (error, httpResponse, body) {
|
|
if (!error && httpResponse.statusCode === 200) {
|
|
var accessToken = body.access_token
|
|
if (accessToken) {
|
|
var content = note.content
|
|
var title = models.Note.decodeTitle(note.title)
|
|
var filename = title.replace('/', ' ') + '.md'
|
|
var gist = {
|
|
'files': {}
|
|
}
|
|
gist.files[filename] = {
|
|
'content': content
|
|
}
|
|
var gistUrl = 'https://api.github.com/gists'
|
|
request({
|
|
url: gistUrl,
|
|
headers: {
|
|
'User-Agent': 'CodiMD',
|
|
'Authorization': 'token ' + accessToken
|
|
},
|
|
method: 'POST',
|
|
json: gist
|
|
}, function (error, httpResponse, body) {
|
|
if (!error && httpResponse.statusCode === 201) {
|
|
res.setHeader('referer', '')
|
|
res.redirect(body.html_url)
|
|
} else {
|
|
return response.errorForbidden(res)
|
|
}
|
|
})
|
|
} else {
|
|
return response.errorForbidden(res)
|
|
}
|
|
} else {
|
|
return response.errorForbidden(res)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function gitlabActions (req, res, next) {
|
|
var noteId = req.params.noteId
|
|
findNote(req, res, function (note) {
|
|
var action = req.params.action
|
|
switch (action) {
|
|
case 'projects':
|
|
gitlabActionProjects(req, res, note)
|
|
break
|
|
default:
|
|
res.redirect(config.serverURL + '/' + noteId)
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
function gitlabActionProjects (req, res, note) {
|
|
if (req.isAuthenticated()) {
|
|
models.User.findOne({
|
|
where: {
|
|
id: req.user.id
|
|
}
|
|
}).then(function (user) {
|
|
if (!user) { return response.errorNotFound(res) }
|
|
var ret = { baseURL: config.gitlab.baseURL, version: config.gitlab.version }
|
|
ret.accesstoken = user.accessToken
|
|
ret.profileid = user.profileid
|
|
request(
|
|
config.gitlab.baseURL + '/api/' + config.gitlab.version + '/projects?membership=yes&per_page=100&access_token=' + user.accessToken,
|
|
function (error, httpResponse, body) {
|
|
if (!error && httpResponse.statusCode === 200) {
|
|
ret.projects = JSON.parse(body)
|
|
return res.send(ret)
|
|
} else {
|
|
return res.send(ret)
|
|
}
|
|
}
|
|
)
|
|
}).catch(function (err) {
|
|
logger.error('gitlab action projects failed: ' + err)
|
|
return response.errorInternalError(res)
|
|
})
|
|
} else {
|
|
return response.errorForbidden(res)
|
|
}
|
|
}
|
|
|
|
function showPublishSlide (req, res, next) {
|
|
var include = [{
|
|
model: models.User,
|
|
as: 'owner'
|
|
}, {
|
|
model: models.User,
|
|
as: 'lastchangeuser'
|
|
}]
|
|
findNote(req, res, function (note) {
|
|
// force to use short id
|
|
var shortid = req.params.shortid
|
|
if ((note.alias && shortid !== note.alias) || (!note.alias && shortid !== note.shortid)) { return res.redirect(config.serverURL + '/p/' + (note.alias || note.shortid)) }
|
|
note.increment('viewcount').then(function (note) {
|
|
if (!note) {
|
|
return response.errorNotFound(res)
|
|
}
|
|
var body = note.content
|
|
var extracted = models.Note.extractMeta(body)
|
|
var markdown = extracted.markdown
|
|
var meta = models.Note.parseMeta(extracted.meta)
|
|
var createtime = note.createdAt
|
|
var updatetime = note.lastchangeAt
|
|
var title = models.Note.decodeTitle(note.title)
|
|
title = models.Note.generateWebTitle(meta.title || title)
|
|
var data = {
|
|
title: title,
|
|
description: meta.description || (markdown ? models.Note.generateDescription(markdown) : null),
|
|
viewcount: note.viewcount,
|
|
createtime: createtime,
|
|
updatetime: updatetime,
|
|
body: markdown,
|
|
theme: meta.slideOptions && utils.isRevealTheme(meta.slideOptions.theme),
|
|
meta: JSON.stringify(extracted.meta),
|
|
owner: note.owner ? note.owner.id : null,
|
|
ownerprofile: note.owner ? models.User.getProfile(note.owner) : null,
|
|
lastchangeuser: note.lastchangeuser ? note.lastchangeuser.id : null,
|
|
lastchangeuserprofile: note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null,
|
|
robots: meta.robots || false, // default allow robots
|
|
GA: meta.GA,
|
|
disqus: meta.disqus,
|
|
cspNonce: res.locals.nonce,
|
|
dnt: req.headers.dnt
|
|
}
|
|
return renderPublishSlide(data, res)
|
|
}).catch(function (err) {
|
|
logger.error(err)
|
|
return response.errorInternalError(res)
|
|
})
|
|
}, include)
|
|
}
|
|
|
|
function renderPublishSlide (data, res) {
|
|
res.set({
|
|
'Cache-Control': 'private' // only cache by client
|
|
})
|
|
res.render('slide.ejs', data)
|
|
}
|
|
|
|
module.exports = response
|