OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
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 { FILE_PREFIX, Macro, 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')

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(`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.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`)
      }
      const { path: actualPath, author } = await pathToActualPath(path, User, Upload, { transaction })
      msg = cant.editArticle(loggedInUser, author.username)
      if (msg) {
        throw new lib.ValidationError([msg], 403)
      }
      const [existing, count] = await Promise.all([
        Upload.count({ where: { path: actualPath } }, { transaction }),
        Upload.count({
          where: {
            path: sequelizeWhereStartsWith(
              sequelize,
              Upload.uidAndPathToUploadPath(author.id, ''),
              '"Upload"."path"'
            ),
          },
          transaction,
        }),
      ])
      if (
        !loggedInUser.admin &&
        existing === 0 &&
        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.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 })
      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`)
      }
      const { path: actualPath, author } = await pathToActualPath(path, User, Upload, { transaction })
      msg = cant.editArticle(loggedInUser, author.username)
      if (msg) {
        throw new lib.ValidationError([msg], 403)
      }
      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);
  }
})

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,
}