From 7e9e08f71822786d6191c215ff5fff73d43f166b Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Tue, 1 Dec 2020 10:37:42 -0500 Subject: [PATCH] feat: server-side routes for handling multiple topic thumbnails closes #8994, requires 'topic-thumb-refactor' branch of composer-default --- src/api/posts.js | 3 +++ src/api/topics.js | 1 + src/controllers/write/topics.js | 33 +++++++++++++++++++++++++ src/middleware/assert.js | 9 +++++++ src/routes/write/topics.js | 6 +++++ src/topics/thumbs.js | 44 +++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+) diff --git a/src/api/posts.js b/src/api/posts.js index e21bbb426b..c96a602484 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -36,6 +36,9 @@ postsAPI.edit = async function (caller, data) { data.req = apiHelpers.buildReqObject(caller); const editResult = await posts.edit(data); + if (editResult.topic.isMainPost) { + await topics.thumbs.commit(data.uuid, editResult.topic.tid); + } if (editResult.topic.renamed) { await events.log({ type: 'topic-rename', diff --git a/src/api/topics.js b/src/api/topics.js index 98d92022a8..e252dd7515 100644 --- a/src/api/topics.js +++ b/src/api/topics.js @@ -34,6 +34,7 @@ topicsAPI.create = async function (caller, data) { } const result = await topics.post(payload); + await topics.thumbs.commit(data.uuid, result.topicData.tid); socketHelpers.emitToUids('event:new_post', { posts: [result.postData] }, [caller.uid]); socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]); diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 0e16f0ebc5..e781948c0b 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -1,9 +1,12 @@ 'use strict'; +const validator = require('validator'); + const api = require('../../api'); const topics = require('../../topics'); const helpers = require('../helpers'); +const uploadsController = require('../uploads'); const Topics = module.exports; @@ -85,3 +88,33 @@ Topics.deleteTags = async (req, res) => { await topics.deleteTopicTags(req.params.tid); helpers.formatApiResponse(200, res); }; + +Topics.addThumb = async (req, res) => { + // req.params.tid could be either a tid (pushing a new thumb to an existing topic) or a post UUID (a new topic being composed) + const id = req.params.tid; + const isUUID = validator.isUUID(id); + + // Sanity-check the tid if it's strictly not a uuid + if (!isUUID && (isNaN(parseInt(id, 10)) || !await topics.exists(req.params.tid))) { + return helpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); + } + /** + * todo test: + * - uuid + * - tid + * - number but not tid + * - random garbage + */ + + const files = await uploadsController.uploadThumb(req, res); // response is handled here, fix this? + + // Add uploaded files to topic zset + await Promise.all(files.map(async (fileObj) => { + await topics.thumbs.associate(id, fileObj.path, isUUID); + })); +}; + +Topics.deleteThumb = async (req, res) => { + await topics.thumbs.delete(req.params.tid, req.query.path); + helpers.formatApiResponse(200, res, await topics.thumbs.get(req.params.tid)); +}; diff --git a/src/middleware/assert.js b/src/middleware/assert.js index 714beb6df3..a25eb933af 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -44,6 +44,15 @@ Assert.topic = helpers.try(async (req, res, next) => { next(); }); +Assert.topicThumb = helpers.try(async (req, res, next) => { + // thumbs are parsed out of req.query + if (!await topics.thumbs.exists(req.params.tid, req.query.path)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:invalid-file]]')); + } + + next(); +}); + Assert.post = helpers.try(async (req, res, next) => { if (!await posts.exists(req.params.pid)) { return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 08bd8b2928..a354dc1c23 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -10,6 +10,9 @@ const setupApiRoute = routeHelpers.setupApiRoute; module.exports = function () { const middlewares = [middleware.authenticate]; + var multipart = require('connect-multiparty'); + var multipartMiddleware = multipart(); + setupApiRoute(router, 'post', '/', [middleware.authenticateOrGuest, middleware.checkRequired.bind(null, ['cid', 'title', 'content'])], controllers.write.topics.create); setupApiRoute(router, 'post', '/:tid', [middleware.authenticateOrGuest, middleware.checkRequired.bind(null, ['content']), middleware.assert.topic], controllers.write.topics.reply); setupApiRoute(router, 'delete', '/:tid', [...middlewares], controllers.write.topics.purge); @@ -31,5 +34,8 @@ module.exports = function () { setupApiRoute(router, 'put', '/:tid/tags', [...middlewares, middleware.checkRequired.bind(null, ['tags']), middleware.assert.topic], controllers.write.topics.addTags); setupApiRoute(router, 'delete', '/:tid/tags', [...middlewares, middleware.assert.topic], controllers.write.topics.deleteTags); + setupApiRoute(router, 'post', '/:tid/thumbs', [multipartMiddleware, middleware.validateFiles, ...middlewares], controllers.write.topics.addThumb); + setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.assert.topic, middleware.assert.topicThumb], controllers.write.topics.deleteThumb); + return router; }; diff --git a/src/topics/thumbs.js b/src/topics/thumbs.js index 63d4a6adee..6cdc9bf799 100644 --- a/src/topics/thumbs.js +++ b/src/topics/thumbs.js @@ -9,6 +9,7 @@ var mime = require('mime'); var validator = require('validator'); var util = require('util'); +const db = require('../database'); var meta = require('../meta'); var image = require('../image'); var file = require('../file'); @@ -24,6 +25,49 @@ function pipeToFile(source, destination, callback) { } const pipeToFileAsync = util.promisify(pipeToFile); +Thumbs.exists = async function (tid, path) { + // TODO: tests + return db.isSortedSetMember(`topic:${tid}:thumbs`, path); +}; + +Thumbs.get = async function (tid) { + const thumbs = await db.getSortedSetRange(`topic:${tid}:thumbs`, 0, -1); + return thumbs.map(thumb => path.join(nconf.get('upload_path'), thumb)); +}; + +Thumbs.associate = async function (id, path, isDraft) { + // Associates a newly uploaded file as a thumb to the passed-in tid + const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; + const numThumbs = await db.sortedSetCard(set); + path = path.replace(nconf.get('upload_path'), ''); + db.sortedSetAdd(set, numThumbs, path); +}; + +Thumbs.commit = async function (uuid, tid) { + // Converts the draft thumb zset to the topic zset (combines thumbs if applicable) + const set = `draft:${uuid}:thumbs`; + const thumbs = await db.getSortedSetRange(set, 0, -1); + await Promise.all(thumbs.map(async path => await Thumbs.associate(tid, path, false))); + await db.delete(set); +}; + +Thumbs.delete = async function (tid, relativePath) { + // TODO: tests + const set = `topic:${tid}:thumbs`; + const absolutePath = path.join(nconf.get('upload_path'), relativePath); + const [associated, existsOnDisk] = await Promise.all([ + db.isSortedSetMember(set, relativePath), + file.exists(absolutePath), + ]); + + if (associated) { + await db.sortedSetRemove(set, relativePath); + } + if (existsOnDisk) { + await file.delete(absolutePath); + } +}; + Thumbs.resizeAndUpload = async function (data) { const allowedExtensions = file.allowedExtensions();