web/api/uploads.js
// This endpoint implements a simplistic in-DB filesystem that stores and retrieves blobs given an input path.
// These should likely be stored in a static file server, but lazy to set that up for now, so I'll
// just store everything in the DB to start with until I regret the choice one day and migrate.
const router = require('express').Router()
const { Op } = require('sequelize')
const { FILE_PREFIX, Macro, RESERVED_ID_SEPARATOR, URL_SEP } = require('ourbigbook')
const { sequelizeWhereStartsWith } = require('ourbigbook/models')
const { ARTICLE_HASH_LIMIT_MAX } = require('ourbigbook/web_api')
const auth = require('../auth')
const lib = require('./lib')
const { ValidationError } = lib
const convert = require('../convert')
const { cant } = require('../front/cant')
const config = require('../front/config')
const { isBoolean, isString } = require('../front/js')
async function pathToActualPath(path, User, Upload, opts={}) {
const ret = await Upload.pathToActualPath(path, User, Upload, opts)
if (!ret.path) {
throw new lib.ValidationError(`username does not exist: ${ret.authorUsername}`, 404)
}
return ret
}
async function get(req, res, next, path) {
try {
const sequelize = req.app.get('sequelize')
const { Upload, User } = sequelize.models
if (!path) {
throw new lib.ValidationError(`path must be given and cannot be empty`)
}
await sequelize.transaction(async (transaction) => {
const { path: actualPath } = await pathToActualPath(path, User, Upload, { transaction })
const upload = await Upload.findOne({ where: { path: actualPath }, transaction })
if (!upload) {
throw new lib.ValidationError(`path does not exist: ${path}`, 404)
}
res.set({
'Content-Type': upload.contentType,
'Last-Modified': upload.updatedAt.toUTCString(),
'Content-Security-Policy': "default-src 'none'",
})
res.write(upload.bytes)
})
return res.end()
} catch(error) {
next(error);
}
}
router.get('/', auth.optional, async function(req, res, next) {
return get(req, res, next, req.query.path)
})
router.get('/profile-picture/:uid', auth.optional, async function(req, res, next) {
try {
const uid = req.params.uid
const sequelize = req.app.get('sequelize')
const { Upload } = sequelize.models
await sequelize.transaction(async (transaction) => {
const upload = await Upload.findOne({ where: { path: `${config.profilePicturePathComponent}/${uid}` }, transaction })
if (!upload) {
throw new lib.ValidationError(`profile picture does not exist for user ID: ${uid}`, 404)
}
res.set({
'Content-Type': upload.contentType,
'Last-Modified': upload.updatedAt.toUTCString(),
'Content-Security-Policy': "default-src 'none'",
})
res.write(upload.bytes)
})
return res.end()
} catch(error) {
next(error);
}
})
router.put('/', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { User, Upload } = sequelize.models
await sequelize.transaction(async (transaction) => {
const loggedInUser = await User.findByPk(req.payload.id, { transaction })
let msg = cant.createArticle(loggedInUser)
if (msg) {
throw new lib.ValidationError([msg], 403)
}
const path = req.query.path
if (!path) {
throw new lib.ValidationError(`path must be given and cannot be empty`)
}
if (path.split(URL_SEP).includes(RESERVED_ID_SEPARATOR)) {
throw new lib.ValidationError('The file path component "-" is reserved for website routes.')
}
const { path: actualPath, author } = await pathToActualPath(path, User, Upload, { transaction })
msg = cant.editArticle(loggedInUser, author.username)
if (msg) {
throw new lib.ValidationError([msg], 403)
}
// Serialize writes in this user's namespace, including creation of paths
// which have no Upload row to lock yet.
await User.findByPk(author.id, { transaction, lock: transaction.LOCK.UPDATE })
const [existing, count] = await Promise.all([
Upload.findOne({ attributes: ['hash'], where: { path: actualPath }, transaction }),
Upload.count({
where: {
path: sequelizeWhereStartsWith(
sequelize,
Upload.uidAndPathToUploadPath(author.id, ''),
'"Upload"."path"'
),
},
transaction,
}),
])
const ifMatch = req.get('If-Match')
if ((req.get('If-None-Match') === '*' && existing) ||
(ifMatch !== undefined && (!existing || ifMatch !== `"${existing.hash}"`))) {
throw new ValidationError('The file has changed. Check the current version before replacing it.', 412)
}
if (
!loggedInUser.admin &&
!existing &&
count >= author.maxUploads
) {
throw new ValidationError(
`You have reached your maximum number of uploads: ${loggedInUser.maxUploads}. ` +
`Please ask an admin to raise it for you: ${config.contactUrl}`,
403
)
}
const bytes = req.body
if (!loggedInUser.admin && bytes.length > loggedInUser.maxUploadSize) {
throw new ValidationError(
`The upload size (${bytes.length} bytes) was larger than your maximum ` +
`upload size (${loggedInUser.maxUploadSize} bytes)`,
403,
)
}
await Upload.upsertSideEffects(Upload.getCreateObj({ bytes, path: actualPath }), { transaction })
})
return res.json({})
} catch(error) {
next(error);
}
})
router.patch('/', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { Upload, User } = sequelize.models
const path = lib.validateParam(req.query, 'path', { validators: [isString] })
const list = lib.validateParam(req.body, 'list', { validators: [isBoolean] })
await sequelize.transaction(async transaction => {
const loggedInUser = await User.findByPk(req.payload.id, { transaction })
const { path: actualPath, author } = await pathToActualPath(path, User, Upload, { transaction })
const msg = cant.editUpload(loggedInUser, author.username)
if (msg) throw new ValidationError([msg], 403)
await User.findByPk(author.id, { transaction, lock: transaction.LOCK.UPDATE })
const upload = await Upload.findOne({ where: { path: actualPath }, attributes: ['id', 'list'], transaction })
if (!upload) throw new ValidationError(`path does not exist: ${path}`, 404)
await upload.update({ list }, { transaction })
})
return res.json({ list })
} catch (error) {
next(error)
}
})
router.delete('/', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { Article, File, User, Upload } = sequelize.models
await sequelize.transaction(async (transaction) => {
const loggedInUser = await User.findByPk(req.payload.id, { transaction })
const msg = cant.deleteUpload(loggedInUser)
if (msg) {
throw new lib.ValidationError([msg], 403)
}
const path = req.query.path
if (!path) {
throw new lib.ValidationError(`path must be given and cannot be empty`)
}
const { path: actualPath, author } = await pathToActualPath(path, User, Upload, { transaction })
await User.findByPk(author.id, { transaction, lock: transaction.LOCK.UPDATE })
const upload = await Upload.findOne({ where: { path: actualPath }, transaction})
if (!upload) {
throw new lib.ValidationError(`path does not exist: ${path}`, 404)
}
const article = await Article.findOne({
where: {
slug:
`${author.username}${Macro.HEADER_SCOPE_SEPARATOR}${FILE_PREFIX}${Macro.HEADER_SCOPE_SEPARATOR}${path.split(URL_SEP).slice(1).join(URL_SEP)}`
},
include: [{
model: File,
as: 'file',
}],
transaction,
})
if (article) {
const ret = await convert.convertArticle({
author,
bodySource: '{file}',
list: false,
sequelize,
render: true,
titleSource: article.file.titleSource,
transaction,
updateNestedSetIndex: false,
})
}
await upload.destroySideEffects({ transaction })
})
return res.json({})
} catch(error) {
next(error);
}
})
// Editors may target another user's namespace when an admin edits their article.
async function getUploadOwnerId(req) {
const username = lib.validateParam(req.query, 'username', { defaultValue: undefined, validators: [isString] })
if (username === undefined) return req.payload.id
const { User } = req.app.get('sequelize').models
const [loggedInUser, owner] = await Promise.all([
User.findByPk(req.payload.id),
User.findOne({ where: { username } }),
])
if (!owner) throw new ValidationError(`username does not exist: ${username}`, 404)
const msg = cant.editArticle(loggedInUser, username)
if (msg) throw new ValidationError([msg], 403)
return owner.id
}
router.get('/metadata', auth.required, async function(req, res, next) {
try {
const { Upload } = req.app.get('sequelize').models
const path = lib.validateParam(req.query, 'path', { validators: [isString] })
const ownerId = await getUploadOwnerId(req)
const upload = await Upload.findOne({
attributes: ['hash', 'contentType', 'size'],
where: { path: Upload.uidAndPathToUploadPath(ownerId, path) },
})
res.set('Cache-Control', 'no-store')
return res.json(upload ? { exists: true, hash: upload.hash, contentType: upload.contentType, size: upload.size }
: { exists: false })
} catch (error) {
next(error)
}
})
router.get('/images', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { Upload } = sequelize.models
const prefix = lib.validateParam(req.query, 'prefix', { defaultValue: '', validators: [isString] })
const [limit, offset] = lib.getLimitAndOffset(req, res, { defaultLimit: 20, limitMax: 100 })
const ownerId = await getUploadOwnerId(req)
const directory = Upload.uidAndPathToUploadPath(ownerId, '') + URL_SEP
const fullPrefix = directory + prefix
const { count, rows } = await Upload.findAndCountAll({
attributes: ['path'],
where: {
path: sequelizeWhereStartsWith(sequelize, directory, '"Upload"."path"'),
contentType: { [Op.like]: 'image/%' },
// Compare a literal prefix: %, _, * and [ in filenames are not wildcards.
[Op.and]: sequelize.where(sequelize.fn('substr', sequelize.col('path'), 1, Array.from(fullPrefix).length), fullPrefix),
},
order: [['path', 'ASC']],
limit,
offset,
})
return res.json({ images: rows.map(row => ({ path: row.path.slice(directory.length) })), count })
} catch (error) {
next(error)
}
})
router.get('/hash', auth.optional, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { Upload, User } = sequelize.models
const [limit, offset] = lib.getLimitAndOffset(req, res, {
limitMax: ARTICLE_HASH_LIMIT_MAX,
})
const authorUsername = req.query.author
// Require it for now, I'm lazy to fetch authors to replace upload/uid with upload/username
// Perhaps author should have been a separate column oops. Nice path sorting is another thing
// that we'd need to thinking about, now we are sorting by the internal UID.
if (!authorUsername) {
throw new lib.ValidationError(`author must be given`)
}
let author
if (authorUsername) {
author = await User.findOne({ where: { username: authorUsername } })
if (!author) {
throw new lib.ValidationError(`username does not exist: "${authorUsername}"`, 404)
}
}
const where = {}
if (author) {
where.path = sequelizeWhereStartsWith(sequelize, Upload.uidAndPathToUploadPath(author.id, ''), 'path')
}
const { count, rows: uploads } = await Upload.findAndCountAll({
attributes: ['path', 'hash'],
limit,
offset,
order: [['path', 'ASC']],
where,
})
return res.json({
uploads: uploads.map(upload => { return {
hash: upload.hash,
path: `${authorUsername}${URL_SEP}${upload.path.split(URL_SEP).slice(2).join(URL_SEP)}`,
}}),
count,
})
} catch(error) {
next(error);
}
})
module.exports = {
get,
router,
}