refactor: move post uploads to post hash (#13533)

* refactor: move post uploads to post hash

* test: add uploads to api definition

* refactor: move thumbs to topic hash

* chore: up composer

* refactor: dont use old zset
This commit is contained in:
Barış Uşaklı
2025-07-07 10:22:24 -04:00
committed by GitHub
parent bfcc36f7cb
commit 24e7cf4a00
24 changed files with 393 additions and 429 deletions

View File

@@ -97,7 +97,7 @@
"multer": "2.0.1", "multer": "2.0.1",
"nconf": "0.13.0", "nconf": "0.13.0",
"nodebb-plugin-2factor": "7.5.10", "nodebb-plugin-2factor": "7.5.10",
"nodebb-plugin-composer-default": "10.2.51", "nodebb-plugin-composer-default": "10.3.0",
"nodebb-plugin-dbsearch": "6.3.0", "nodebb-plugin-dbsearch": "6.3.0",
"nodebb-plugin-emoji": "6.0.3", "nodebb-plugin-emoji": "6.0.3",
"nodebb-plugin-emoji-android": "4.1.1", "nodebb-plugin-emoji-android": "4.1.1",

View File

@@ -300,6 +300,8 @@ PostDataObject:
type: boolean type: boolean
attachments: attachments:
type: array type: array
uploads:
type: array
replies: replies:
type: object type: object
properties: properties:

View File

@@ -83,55 +83,6 @@ post:
type: string type: string
name: name:
type: string type: string
put:
tags:
- topics
summary: migrate topic thumbnail
description: This operation migrates a thumbnails from a topic or draft, to another tid or draft.
parameters:
- in: path
name: tid
schema:
type: string
required: true
description: a valid topic id or draft uuid
example: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
tid:
type: string
description: a valid topic id or draft uuid
example: '1'
responses:
'200':
description: Topic thumbnails migrated
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: array
description: A list of the topic thumbnails in the destination topic
items:
type: object
properties:
id:
type: string
name:
type: string
path:
type: string
url:
type: string
description: Path to a topic thumbnail
delete: delete:
tags: tags:
- topics - topics

View File

@@ -2,14 +2,14 @@ put:
tags: tags:
- topics - topics
summary: reorder topic thumbnail summary: reorder topic thumbnail
description: This operation sets the order for a topic thumbnail. It can handle either topics (if a valid `tid` is passed in), or drafts. A 404 is returned if the topic or draft does not actually contain that thumbnail path. Paths passed in should **not** contain the path to the uploads folder (`config.upload_url` on client side) description: This operation sets the order for a topic thumbnail. A 404 is returned if the topic does not contain path. Paths passed in should **not** contain the path to the uploads folder (`config.upload_url` on client side)
parameters: parameters:
- in: path - in: path
name: tid name: tid
schema: schema:
type: string type: string
required: true required: true
description: a valid topic id or draft uuid description: a valid topic id
example: 2 example: 2
requestBody: requestBody:
required: true required: true

View File

@@ -176,6 +176,12 @@ define('forum/topic/events', [
}); });
} }
if (data.topic.thumbsupdated) {
require(['topicThumbs'], function (topicThumbs) {
topicThumbs.updateTopicThumbs(data.topic.tid);
});
}
postTools.removeMenu(components.get('post', 'pid', data.post.pid)); postTools.removeMenu(components.get('post', 'pid', data.post.pid));
} }

View File

@@ -34,6 +34,7 @@ module.exports = function (utils, Benchpress, relative_path) {
humanReadableNumber, humanReadableNumber,
formattedNumber, formattedNumber,
txEscape, txEscape,
uploadBasename,
generatePlaceholderWave, generatePlaceholderWave,
register, register,
__escape: identity, __escape: identity,
@@ -379,6 +380,12 @@ module.exports = function (utils, Benchpress, relative_path) {
return String(text).replace(/%/g, '%').replace(/,/g, ','); return String(text).replace(/%/g, '%').replace(/,/g, ',');
} }
function uploadBasename(str, sep = '/') {
const hasTimestampPrefix = /^\d+-/;
const name = str.substr(str.lastIndexOf(sep) + 1);
return hasTimestampPrefix.test(name) ? name.slice(14) : name;
}
function generatePlaceholderWave(items) { function generatePlaceholderWave(items) {
const html = items.map((i) => { const html = items.map((i) => {
if (i === 'divider') { if (i === 'divider') {

View File

@@ -7,23 +7,27 @@ define('topicThumbs', [
Thumbs.get = id => api.get(`/topics/${id}/thumbs`, { thumbsOnly: 1 }); Thumbs.get = id => api.get(`/topics/${id}/thumbs`, { thumbsOnly: 1 });
Thumbs.getByPid = pid => api.get(`/posts/${encodeURIComponent(pid)}`, {}).then(post => Thumbs.get(post.tid));
Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, { Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, {
path: path, path: path,
}); });
Thumbs.updateTopicThumbs = async (tid) => {
const thumbs = await Thumbs.get(tid);
const html = await app.parseAndTranslate('partials/topic/thumbs', { thumbs });
$('[component="topic/thumb/list"]').html(html);
};
Thumbs.deleteAll = (id) => { Thumbs.deleteAll = (id) => {
Thumbs.get(id).then((thumbs) => { Thumbs.get(id).then((thumbs) => {
Promise.all(thumbs.map(thumb => Thumbs.delete(id, thumb.url))); Promise.all(thumbs.map(thumb => Thumbs.delete(id, thumb.url)));
}); });
}; };
Thumbs.upload = id => new Promise((resolve) => { Thumbs.upload = () => new Promise((resolve) => {
uploader.show({ uploader.show({
title: '[[topic:composer.thumb-title]]', title: '[[topic:composer.thumb-title]]',
method: 'put', method: 'put',
route: config.relative_path + `/api/v3/topics/${id}/thumbs`, route: config.relative_path + `/api/topic/thumb/upload`,
}, function (url) { }, function (url) {
resolve(url); resolve(url);
}); });
@@ -32,24 +36,16 @@ define('topicThumbs', [
Thumbs.modal = {}; Thumbs.modal = {};
Thumbs.modal.open = function (payload) { Thumbs.modal.open = function (payload) {
const { id, pid } = payload; const { id, postData } = payload;
let { modal } = payload; let { modal } = payload;
let numThumbs; const thumbs = postData.thumbs || [];
return new Promise((resolve) => { return new Promise((resolve) => {
Promise.all([ Benchpress.render('modals/topic-thumbs', { thumbs }).then((html) => {
Thumbs.get(id),
pid ? Thumbs.getByPid(pid) : [],
]).then(results => new Promise((resolve) => {
const thumbs = results.reduce((memo, cur) => memo.concat(cur));
numThumbs = thumbs.length;
resolve(thumbs);
})).then(thumbs => Benchpress.render('modals/topic-thumbs', { thumbs })).then((html) => {
if (modal) { if (modal) {
translator.translate(html, function (translated) { translator.translate(html, function (translated) {
modal.find('.bootbox-body').html(translated); modal.find('.bootbox-body').html(translated);
Thumbs.modal.handleSort({ modal, numThumbs }); Thumbs.modal.handleSort({ modal, thumbs });
}); });
} else { } else {
modal = bootbox.dialog({ modal = bootbox.dialog({
@@ -62,7 +58,11 @@ define('topicThumbs', [
label: '<i class="fa fa-plus"></i> [[modules:thumbs.modal.add]]', label: '<i class="fa fa-plus"></i> [[modules:thumbs.modal.add]]',
className: 'btn-success', className: 'btn-success',
callback: () => { callback: () => {
Thumbs.upload(id).then(() => { Thumbs.upload().then((thumbUrl) => {
postData.thumbs.push(
thumbUrl.replace(new RegExp(`^${config.upload_url}`), '')
);
Thumbs.modal.open({ ...payload, modal }); Thumbs.modal.open({ ...payload, modal });
require(['composer'], (composer) => { require(['composer'], (composer) => {
composer.updateThumbCount(id, $(`[component="composer"][data-uuid="${id}"]`)); composer.updateThumbCount(id, $(`[component="composer"][data-uuid="${id}"]`));
@@ -79,7 +79,7 @@ define('topicThumbs', [
}, },
}); });
Thumbs.modal.handleDelete({ ...payload, modal }); Thumbs.modal.handleDelete({ ...payload, modal });
Thumbs.modal.handleSort({ modal, numThumbs }); Thumbs.modal.handleSort({ modal, thumbs });
} }
}); });
}); });
@@ -94,42 +94,42 @@ define('topicThumbs', [
if (!ok) { if (!ok) {
return; return;
} }
const id = ev.target.closest('[data-id]').getAttribute('data-id');
const path = ev.target.closest('[data-path]').getAttribute('data-path'); const path = ev.target.closest('[data-path]').getAttribute('data-path');
api.del(`/topics/${id}/thumbs`, { const postData = payload.postData;
path: path, if (postData && postData.thumbs && postData.thumbs.includes(path)) {
}).then(() => { postData.thumbs = postData.thumbs.filter(thumb => thumb !== path);
Thumbs.modal.open(payload); Thumbs.modal.open(payload);
require(['composer'], (composer) => { require(['composer'], (composer) => {
composer.updateThumbCount(uuid, $(`[component="composer"][data-uuid="${uuid}"]`)); composer.updateThumbCount(uuid, $(`[component="composer"][data-uuid="${uuid}"]`));
}); });
}).catch(alerts.error); }
}); });
} }
}); });
}; };
Thumbs.modal.handleSort = ({ modal, numThumbs }) => { Thumbs.modal.handleSort = ({ modal, thumbs }) => {
if (numThumbs > 1) { if (thumbs.length > 1) {
const selectorEl = modal.find('.topic-thumbs-modal'); const selectorEl = modal.find('.topic-thumbs-modal');
selectorEl.sortable({ selectorEl.sortable({
items: '[data-id]', items: '[data-path]',
});
selectorEl.on('sortupdate', function () {
if (!thumbs) return;
const newOrder = [];
selectorEl.find('[data-path]').each(function () {
const path = $(this).attr('data-path');
const thumb = thumbs.find(t => t === path);
if (thumb) {
newOrder.push(thumb);
}
});
// Mutate thumbs array in place
thumbs.length = 0;
Array.prototype.push.apply(thumbs, newOrder);
}); });
selectorEl.on('sortupdate', Thumbs.modal.handleSortChange);
} }
}; };
Thumbs.modal.handleSortChange = (ev, ui) => {
const items = ui.item.get(0).parentNode.querySelectorAll('[data-id]');
Array.from(items).forEach((el, order) => {
const id = el.getAttribute('data-id');
let path = el.getAttribute('data-path');
path = path.replace(new RegExp(`^${config.upload_url}`), '');
api.put(`/topics/${id}/thumbs/order`, { path, order }).catch(alerts.error);
});
};
return Thumbs; return Thumbs;
}); });

View File

@@ -715,8 +715,12 @@ Mocks.notes.public = async (post) => {
// Special handling for main posts (as:Article w/ as:Note preview) // Special handling for main posts (as:Article w/ as:Note preview)
const noteAttachment = isMainPost ? [...attachment] : null; const noteAttachment = isMainPost ? [...attachment] : null;
const uploads = await posts.uploads.listWithSizes(post.pid); const [uploads, thumbs] = await Promise.all([
const isThumb = await db.isSortedSetMembers(`topic:${post.tid}:thumbs`, uploads.map(u => u.name)); posts.uploads.listWithSizes(post.pid),
topics.getTopicField(post.tid, 'thumbs'),
]);
const isThumb = uploads.map(u => Array.isArray(thumbs) ? thumbs.includes(u.name) : false);
uploads.forEach(({ name, width, height }, idx) => { uploads.forEach(({ name, width, height }, idx) => {
const mediaType = mime.getType(name); const mediaType = mime.getType(name);
const url = `${nconf.get('url') + nconf.get('upload_url')}/${name}`; const url = `${nconf.get('url') + nconf.get('upload_url')}/${name}`;

View File

@@ -120,9 +120,7 @@ postsAPI.edit = async function (caller, data) {
data.timestamp = parseInt(data.timestamp, 10) || Date.now(); data.timestamp = parseInt(data.timestamp, 10) || Date.now();
const editResult = await posts.edit(data); const editResult = await posts.edit(data);
if (editResult.topic.isMainPost) {
await topics.thumbs.migrate(data.uuid, editResult.topic.tid);
}
const selfPost = parseInt(caller.uid, 10) === parseInt(editResult.post.uid, 10); const selfPost = parseInt(caller.uid, 10) === parseInt(editResult.post.uid, 10);
if (!selfPost && editResult.post.changed) { if (!selfPost && editResult.post.changed) {
await events.log({ await events.log({

View File

@@ -1,7 +1,5 @@
'use strict'; 'use strict';
const validator = require('validator');
const user = require('../user'); const user = require('../user');
const topics = require('../topics'); const topics = require('../topics');
const categories = require('../categories'); const categories = require('../categories');
@@ -23,17 +21,13 @@ const socketHelpers = require('../socket.io/helpers');
const topicsAPI = module.exports; const topicsAPI = module.exports;
topicsAPI._checkThumbPrivileges = async function ({ tid, uid }) { topicsAPI._checkThumbPrivileges = async function ({ tid, uid }) {
// 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 isUUID = validator.isUUID(tid);
// Sanity-check the tid if it's strictly not a uuid // Sanity-check the tid if it's strictly not a uuid
if (!isUUID && (isNaN(parseInt(tid, 10)) || !await topics.exists(tid))) { if ((isNaN(parseInt(tid, 10)) || !await topics.exists(tid))) {
throw new Error('[[error:no-topic]]'); throw new Error('[[error:no-topic]]');
} }
// While drafts are not protected, tids are // While drafts are not protected, tids are
if (!isUUID && !await privileges.topics.canEdit(tid, uid)) { if (!await privileges.topics.canEdit(tid, uid)) {
throw new Error('[[error:no-privileges]]'); throw new Error('[[error:no-privileges]]');
} }
}; };
@@ -80,7 +74,6 @@ topicsAPI.create = async function (caller, data) {
} }
const result = await topics.post(payload); const result = await topics.post(payload);
await topics.thumbs.migrate(data.uuid, result.topicData.tid);
socketHelpers.emitToUids('event:new_post', { posts: [result.postData] }, [caller.uid]); socketHelpers.emitToUids('event:new_post', { posts: [result.postData] }, [caller.uid]);
socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]); socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]);
@@ -233,17 +226,6 @@ topicsAPI.getThumbs = async (caller, { tid, thumbsOnly }) => {
return await topics.thumbs.get(tid, { thumbsOnly }); return await topics.thumbs.get(tid, { thumbsOnly });
}; };
// topicsAPI.addThumb
topicsAPI.migrateThumbs = async (caller, { from, to }) => {
await Promise.all([
topicsAPI._checkThumbPrivileges({ tid: from, uid: caller.uid }),
topicsAPI._checkThumbPrivileges({ tid: to, uid: caller.uid }),
]);
await topics.thumbs.migrate(from, to);
};
topicsAPI.deleteThumb = async (caller, { tid, path }) => { topicsAPI.deleteThumb = async (caller, { tid, path }) => {
await topicsAPI._checkThumbPrivileges({ tid: tid, uid: caller.uid }); await topicsAPI._checkThumbPrivileges({ tid: tid, uid: caller.uid });
await topics.thumbs.delete(tid, path); await topics.thumbs.delete(tid, path);

View File

@@ -138,25 +138,18 @@ Topics.addThumb = async (req, res) => {
const files = await uploadsController.uploadThumb(req, res); // response is handled here const files = await uploadsController.uploadThumb(req, res); // response is handled here
// Add uploaded files to topic zset // Add uploaded files to topic hash
if (files && files.length) { if (files && files.length) {
await Promise.all(files.map(async (fileObj) => { for (const fileObj of files) {
// eslint-disable-next-line no-await-in-loop
await topics.thumbs.associate({ await topics.thumbs.associate({
id: req.params.tid, id: req.params.tid,
path: fileObj.url, path: fileObj.url,
}); });
})); }
} }
}; };
Topics.migrateThumbs = async (req, res) => {
await api.topics.migrateThumbs(req, {
from: req.params.tid,
to: req.body.tid,
});
helpers.formatApiResponse(200, res, await api.topics.getThumbs(req, { tid: req.body.tid }));
};
Topics.deleteThumb = async (req, res) => { Topics.deleteThumb = async (req, res) => {
if (!req.body.path.startsWith('http')) { if (!req.body.path.startsWith('http')) {

View File

@@ -70,5 +70,13 @@ function modifyPost(post, fields) {
if (!fields.length || fields.includes('attachments')) { if (!fields.length || fields.includes('attachments')) {
post.attachments = (post.attachments || '').split(',').filter(Boolean); post.attachments = (post.attachments || '').split(',').filter(Boolean);
} }
if (!fields.length || fields.includes('uploads')) {
try {
post.uploads = post.uploads ? JSON.parse(post.uploads) : [];
} catch (err) {
post.uploads = [];
}
}
} }
} }

View File

@@ -29,7 +29,7 @@ module.exports = function (Posts) {
} }
const topicData = await topics.getTopicFields(postData.tid, [ const topicData = await topics.getTopicFields(postData.tid, [
'cid', 'mainPid', 'title', 'timestamp', 'scheduled', 'slug', 'tags', 'cid', 'mainPid', 'title', 'timestamp', 'scheduled', 'slug', 'tags', 'thumbs',
]); ]);
await scheduledTopicCheck(data, topicData); await scheduledTopicCheck(data, topicData);
@@ -142,6 +142,15 @@ module.exports = function (Posts) {
await topics.validateTags(data.tags, topicData.cid, data.uid, tid); await topics.validateTags(data.tags, topicData.cid, data.uid, tid);
} }
const thumbs = topics.thumbs.filterThumbs(data.thumbs);
const thumbsupdated = Array.isArray(data.thumbs) &&
!_.isEqual(data.thumbs, topicData.thumbs);
if (thumbsupdated) {
newTopicData.thumbs = JSON.stringify(thumbs);
newTopicData.numThumbs = thumbs.length;
}
const results = await plugins.hooks.fire('filter:topic.edit', { const results = await plugins.hooks.fire('filter:topic.edit', {
req: data.req, req: data.req,
topic: newTopicData, topic: newTopicData,
@@ -172,6 +181,7 @@ module.exports = function (Posts) {
renamed: renamed, renamed: renamed,
tagsupdated: tagsupdated, tagsupdated: tagsupdated,
tags: tags, tags: tags,
thumbsupdated: thumbsupdated,
oldTags: topicData.tags, oldTags: topicData.tags,
rescheduled: rescheduling(data, topicData), rescheduled: rescheduling(data, topicData),
}; };

View File

@@ -46,12 +46,14 @@ module.exports = function (Posts) {
Posts.uploads.sync = async function (pid) { Posts.uploads.sync = async function (pid) {
// Scans a post's content and updates sorted set of uploads // Scans a post's content and updates sorted set of uploads
const [content, currentUploads, isMainPost] = await Promise.all([ const [postData, isMainPost] = await Promise.all([
Posts.getPostField(pid, 'content'), Posts.getPostFields(pid, ['content', 'uploads']),
Posts.uploads.list(pid),
Posts.isMain(pid), Posts.isMain(pid),
]); ]);
const content = postData.content || '';
const currentUploads = postData.uploads || [];
// Extract upload file paths from post content // Extract upload file paths from post content
let match = searchRegex.exec(content); let match = searchRegex.exec(content);
let uploads = new Set(); let uploads = new Set();
@@ -75,14 +77,19 @@ module.exports = function (Posts) {
// Create add/remove sets // Create add/remove sets
const add = uploads.filter(path => !currentUploads.includes(path)); const add = uploads.filter(path => !currentUploads.includes(path));
const remove = currentUploads.filter(path => !uploads.includes(path)); const remove = currentUploads.filter(path => !uploads.includes(path));
await Promise.all([ await Posts.uploads.associate(pid, add);
Posts.uploads.associate(pid, add), await Posts.uploads.dissociate(pid, remove);
Posts.uploads.dissociate(pid, remove),
]);
}; };
Posts.uploads.list = async function (pid) { Posts.uploads.list = async function (pids) {
return await db.getSortedSetMembers(`post:${pid}:uploads`); const isArray = Array.isArray(pids);
if (isArray) {
const uploads = await Posts.getPostsFields(pids, ['uploads']);
return uploads.map(p => p.uploads || []);
}
const uploads = await Posts.getPostField(pids, 'uploads');
return uploads;
}; };
Posts.uploads.listWithSizes = async function (pid) { Posts.uploads.listWithSizes = async function (pid) {
@@ -157,33 +164,38 @@ module.exports = function (Posts) {
}; };
Posts.uploads.associate = async function (pid, filePaths) { Posts.uploads.associate = async function (pid, filePaths) {
// Adds an upload to a post's sorted set of uploads
filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths; filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths;
if (!filePaths.length) { if (!filePaths.length) {
return; return;
} }
filePaths = await _filterValidPaths(filePaths); // Only process files that exist and are within uploads directory filePaths = await _filterValidPaths(filePaths); // Only process files that exist and are within uploads directory
const currentUploads = await Posts.uploads.list(pid);
filePaths.forEach((path) => {
if (!currentUploads.includes(path)) {
currentUploads.push(path);
}
});
const now = Date.now(); const now = Date.now();
const scores = filePaths.map((p, i) => now + i);
const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]); const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]);
await Promise.all([ await Promise.all([
db.sortedSetAdd(`post:${pid}:uploads`, scores, filePaths), db.setObjectField(`post:${pid}`, 'uploads', JSON.stringify(currentUploads)),
db.sortedSetAddBulk(bulkAdd), db.sortedSetAddBulk(bulkAdd),
Posts.uploads.saveSize(filePaths), Posts.uploads.saveSize(filePaths),
]); ]);
}; };
Posts.uploads.dissociate = async function (pid, filePaths) { Posts.uploads.dissociate = async function (pid, filePaths) {
// Removes an upload from a post's sorted set of uploads
filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths; filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths;
if (!filePaths.length) { if (!filePaths.length) {
return; return;
} }
let currentUploads = await Posts.uploads.list(pid);
currentUploads = currentUploads.filter(upload => !filePaths.includes(upload));
const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]); const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]);
const promises = [ const promises = [
db.sortedSetRemove(`post:${pid}:uploads`, filePaths), db.setObjectField(`post:${pid}`, 'uploads', JSON.stringify(currentUploads)),
db.sortedSetRemoveBulk(bulkRemove), db.sortedSetRemoveBulk(bulkRemove),
]; ];

View File

@@ -36,6 +36,7 @@ module.exports = function (app, middleware, controllers) {
]; ];
router.post('/post/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadPost)); router.post('/post/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadPost));
router.post('/topic/thumb/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadThumb));
router.post('/user/:userslug/uploadpicture', [ router.post('/user/:userslug/uploadpicture', [
...middlewares, ...middlewares,
...postMiddlewares, ...postMiddlewares,

View File

@@ -41,7 +41,7 @@ module.exports = function () {
...middlewares, ...middlewares,
], controllers.write.topics.addThumb); ], controllers.write.topics.addThumb);
setupApiRoute(router, 'put', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['tid'])], controllers.write.topics.migrateThumbs);
setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['path'])], controllers.write.topics.deleteThumb); setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['path'])], controllers.write.topics.deleteThumb);
setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs); setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs);

View File

@@ -41,6 +41,12 @@ module.exports = function (Topics) {
topicData.tags = data.tags.join(','); topicData.tags = data.tags.join(',');
} }
if (Array.isArray(data.thumbs) && data.thumbs.length) {
const thumbs = Topics.thumbs.filterThumbs(data.thumbs);
topicData.thumbs = JSON.stringify(thumbs);
topicData.numThumbs = thumbs.length;
}
const result = await plugins.hooks.fire('filter:topic.create', { topic: topicData, data: data }); const result = await plugins.hooks.fire('filter:topic.create', { topic: topicData, data: data });
topicData = result.topic; topicData = result.topic;
await db.setObject(`topic:${topicData.tid}`, topicData); await db.setObject(`topic:${topicData.tid}`, topicData);

View File

@@ -140,4 +140,12 @@ function modifyTopic(topic, fields) {
}; };
}); });
} }
if (fields.includes('thumbs') || !fields.length) {
try {
topic.thumbs = topic.thumbs ? JSON.parse(String(topic.thumbs || '[]')) : [];
} catch (e) {
topic.thumbs = [];
}
}
} }

View File

@@ -5,29 +5,26 @@ const _ = require('lodash');
const nconf = require('nconf'); const nconf = require('nconf');
const path = require('path'); const path = require('path');
const mime = require('mime'); const mime = require('mime');
const db = require('../database');
const file = require('../file');
const plugins = require('../plugins'); const plugins = require('../plugins');
const posts = require('../posts'); const posts = require('../posts');
const meta = require('../meta'); const meta = require('../meta');
const cache = require('../cache');
const topics = module.parent.exports; const topics = module.parent.exports;
const Thumbs = module.exports; const Thumbs = module.exports;
Thumbs.exists = async function (id, path) { const upload_url = nconf.get('relative_path') + nconf.get('upload_url');
const isDraft = !await topics.exists(id); const upload_path = nconf.get('upload_path');
const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`;
return db.isSortedSetMember(set, path); Thumbs.exists = async function (tid, path) {
const thumbs = await topics.getTopicField(tid, 'thumbs');
return thumbs.includes(path);
}; };
Thumbs.load = async function (topicData) { Thumbs.load = async function (topicData) {
const mainPids = topicData.filter(Boolean).map(t => t.mainPid); const mainPids = topicData.filter(Boolean).map(t => t.mainPid);
let hashes = await posts.getPostsFields(mainPids, ['attachments']); const mainPostData = await posts.getPostsFields(mainPids, ['attachments', 'uploads']);
const hasUploads = await db.exists(mainPids.map(pid => `post:${pid}:uploads`)); const hasUploads = mainPostData.map(p => Array.isArray(p.uploads) && p.uploads.length > 0);
hashes = hashes.map(o => o.attachments); const hashes = mainPostData.map(o => o.attachments);
let hasThumbs = topicData.map((t, idx) => t && let hasThumbs = topicData.map((t, idx) => t &&
(parseInt(t.numThumbs, 10) > 0 || (parseInt(t.numThumbs, 10) > 0 ||
!!(hashes[idx] && hashes[idx].length) || !!(hashes[idx] && hashes[idx].length) ||
@@ -36,11 +33,70 @@ Thumbs.load = async function (topicData) {
const topicsWithThumbs = topicData.filter((tid, idx) => hasThumbs[idx]); const topicsWithThumbs = topicData.filter((tid, idx) => hasThumbs[idx]);
const tidsWithThumbs = topicsWithThumbs.map(t => t.tid); const tidsWithThumbs = topicsWithThumbs.map(t => t.tid);
const thumbs = await Thumbs.get(tidsWithThumbs);
const thumbs = await loadFromTopicData(topicsWithThumbs);
const tidToThumbs = _.zipObject(tidsWithThumbs, thumbs); const tidToThumbs = _.zipObject(tidsWithThumbs, thumbs);
return topicData.map(t => (t && t.tid ? (tidToThumbs[t.tid] || []) : [])); return topicData.map(t => (t && t.tid ? (tidToThumbs[t.tid] || []) : []));
}; };
async function loadFromTopicData(topicData, options = {}) {
const tids = topicData.map(t => t.tid);
const thumbs = topicData.map(t => t ? t.thumbs : []);
if (!options.thumbsOnly) {
const mainPids = topicData.map(t => t.mainPid);
const [mainPidUploads, mainPidAttachments] = await Promise.all([
posts.uploads.list(mainPids),
posts.attachments.get(mainPids),
]);
// Add uploaded media to thumb sets
mainPidUploads.forEach((uploads, idx) => {
uploads = uploads.filter((upload) => {
const type = mime.getType(upload);
return !thumbs[idx].includes(upload) && type && type.startsWith('image/');
});
if (uploads.length) {
thumbs[idx].push(...uploads);
}
});
// Add attachments to thumb sets
mainPidAttachments.forEach((attachments, idx) => {
attachments = attachments.filter(
attachment => !thumbs[idx].includes(attachment.url) && (attachment.mediaType && attachment.mediaType.startsWith('image/'))
);
if (attachments.length) {
thumbs[idx].push(...attachments.map(attachment => attachment.url));
}
});
}
const hasTimestampPrefix = /^\d+-/;
let response = thumbs.map((thumbSet, idx) => thumbSet.map(thumb => ({
id: String(tids[idx]),
name: (() => {
const name = path.basename(thumb);
return hasTimestampPrefix.test(name) ? name.slice(14) : name;
})(),
path: thumb,
url: thumb.startsWith('http') ?
thumb :
path.posix.join(upload_url, thumb.replace(/\\/g, '/')),
})));
({ thumbs: response } = await plugins.hooks.fire('filter:topics.getThumbs', {
tids,
thumbsOnly: options.thumbsOnly,
thumbs: response,
}));
return response;
};
Thumbs.get = async function (tids, options) { Thumbs.get = async function (tids, options) {
// Allow singular or plural usage // Allow singular or plural usage
let singular = false; let singular = false;
@@ -54,118 +110,77 @@ Thumbs.get = async function (tids, options) {
thumbsOnly: false, thumbsOnly: false,
}; };
} }
const isDraft = (await topics.exists(tids)).map(exists => !exists);
if (!meta.config.allowTopicsThumbnail || !tids.length) { if (!meta.config.allowTopicsThumbnail || !tids.length) {
return singular ? [] : tids.map(() => []); return singular ? [] : tids.map(() => []);
} }
const hasTimestampPrefix = /^\d+-/; const topicData = await topics.getTopicsFields(tids, ['tid', 'mainPid', 'thumbs']);
const upload_url = nconf.get('relative_path') + nconf.get('upload_url'); const response = await loadFromTopicData(topicData, options);
const sets = tids.map((tid, idx) => `${isDraft[idx] ? 'draft' : 'topic'}:${tid}:thumbs`); return singular ? response[0] : response;
const thumbs = await Promise.all(sets.map(getThumbs));
let mainPids = await topics.getTopicsFields(tids, ['mainPid']);
mainPids = mainPids.map(o => o.mainPid);
if (!options.thumbsOnly) {
// Add uploaded media to thumb sets
const mainPidUploads = await Promise.all(mainPids.map(posts.uploads.list));
mainPidUploads.forEach((uploads, idx) => {
uploads = uploads.filter((upload) => {
const type = mime.getType(upload);
return !thumbs[idx].includes(upload) && type && type.startsWith('image/');
});
if (uploads.length) {
thumbs[idx].push(...uploads);
}
});
// Add attachments to thumb sets
const mainPidAttachments = await posts.attachments.get(mainPids);
mainPidAttachments.forEach((attachments, idx) => {
attachments = attachments.filter(
attachment => !thumbs[idx].includes(attachment.url) && (attachment.mediaType && attachment.mediaType.startsWith('image/'))
);
if (attachments.length) {
thumbs[idx].push(...attachments.map(attachment => attachment.url));
}
});
}
let response = thumbs.map((thumbSet, idx) => thumbSet.map(thumb => ({
id: tids[idx],
name: (() => {
const name = path.basename(thumb);
return hasTimestampPrefix.test(name) ? name.slice(14) : name;
})(),
path: thumb,
url: thumb.startsWith('http') ? thumb : path.posix.join(upload_url, thumb.replace(/\\/g, '/')),
})));
({ thumbs: response } = await plugins.hooks.fire('filter:topics.getThumbs', {
tids,
thumbsOnly: options.thumbsOnly,
thumbs: response,
}));
return singular ? response.pop() : response;
}; };
async function getThumbs(set) {
const cached = cache.get(set);
if (cached !== undefined) {
return cached.slice();
}
const thumbs = await db.getSortedSetRange(set, 0, -1);
cache.set(set, thumbs);
return thumbs.slice();
}
Thumbs.associate = async function ({ id, path, score }) { Thumbs.associate = async function ({ id, path, score }) {
// Associates a newly uploaded file as a thumb to the passed-in draft or topic // Associates a newly uploaded file as a thumb to the passed-in topic
const isDraft = !await topics.exists(id); const topicData = await topics.getTopicData(id);
if (!topicData) {
return;
}
const isLocal = !path.startsWith('http'); const isLocal = !path.startsWith('http');
const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`;
const numThumbs = await db.sortedSetCard(set);
// Normalize the path to allow for changes in upload_path (and so upload_url can be appended if needed) // Normalize the path to allow for changes in upload_path (and so upload_url can be appended if needed)
if (isLocal) { if (isLocal) {
path = path.replace(nconf.get('relative_path'), ''); path = path.replace(nconf.get('relative_path'), '');
path = path.replace(nconf.get('upload_url'), ''); path = path.replace(nconf.get('upload_url'), '');
} }
await db.sortedSetAdd(set, isFinite(score) ? score : numThumbs, path);
if (!isDraft) {
const numThumbs = await db.sortedSetCard(set);
await topics.setTopicField(id, 'numThumbs', numThumbs);
}
cache.del(set);
// Associate thumbnails with the main pid (only on local upload) if (Array.isArray(topicData.thumbs)) {
if (!isDraft && isLocal) { const currentIdx = topicData.thumbs.indexOf(path);
const mainPid = (await topics.getMainPids([id]))[0]; const insertIndex = (typeof score === 'number' && score >= 0 && score < topicData.thumbs.length) ?
await posts.uploads.associate(mainPid, path); score :
topicData.thumbs.length;
if (currentIdx !== -1) {
// Remove from current position
topicData.thumbs.splice(currentIdx, 1);
// Adjust insertIndex if needed
const adjustedIndex = currentIdx < insertIndex ? insertIndex - 1 : insertIndex;
topicData.thumbs.splice(adjustedIndex, 0, path);
} else {
topicData.thumbs.splice(insertIndex, 0, path);
}
await topics.setTopicFields(id, {
thumbs: JSON.stringify(topicData.thumbs),
numThumbs: topicData.thumbs.length,
});
// Associate thumbnails with the main pid (only on local upload)
if (isLocal && currentIdx === -1) {
await posts.uploads.associate(topicData.mainPid, path);
}
} }
}; };
Thumbs.migrate = async function (uuid, id) { Thumbs.filterThumbs = function (thumbs) {
// Converts the draft thumb zset to the topic zset (combines thumbs if applicable) if (!Array.isArray(thumbs)) {
const set = `draft:${uuid}:thumbs`; return [];
const thumbs = await db.getSortedSetRangeWithScores(set, 0, -1); }
await Promise.all(thumbs.map(async thumb => await Thumbs.associate({ thumbs = thumbs.filter((thumb) => {
id, if (thumb.startsWith('http')) {
path: thumb.value, return true;
score: thumb.score, }
}))); // ensure it is in upload path
await db.delete(set); const fullPath = path.join(upload_path, thumb);
cache.del(set); return fullPath.startsWith(upload_path);
});
return thumbs;
}; };
Thumbs.delete = async function (id, relativePaths) { Thumbs.delete = async function (tid, relativePaths) {
const isDraft = !await topics.exists(id); const topicData = await topics.getTopicData(tid);
const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; if (!topicData) {
return;
}
if (typeof relativePaths === 'string') { if (typeof relativePaths === 'string') {
relativePaths = [relativePaths]; relativePaths = [relativePaths];
@@ -173,48 +188,28 @@ Thumbs.delete = async function (id, relativePaths) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
} }
const absolutePaths = relativePaths.map(relativePath => path.join(nconf.get('upload_path'), relativePath)); const toRemove = relativePaths.map(
const [associated, existsOnDisk] = await Promise.all([ relativePath => topicData.thumbs.includes(relativePath) ? relativePath : null
db.isSortedSetMembers(set, relativePaths), ).filter(Boolean);
Promise.all(absolutePaths.map(async absolutePath => file.exists(absolutePath))),
]);
const toRemove = [];
const toDelete = [];
relativePaths.forEach((relativePath, idx) => {
if (associated[idx]) {
toRemove.push(relativePath);
}
if (existsOnDisk[idx]) {
toDelete.push(absolutePaths[idx]);
}
});
await db.sortedSetRemove(set, toRemove);
if (isDraft && toDelete.length) { // drafts only; post upload dissociation handles disk deletion for topics
await Promise.all(toDelete.map(path => file.delete(path)));
}
if (toRemove.length && !isDraft) {
const topics = require('.');
const mainPid = (await topics.getMainPids([id]))[0];
if (toRemove.length) {
const { mainPid } = topicData.mainPid;
topicData.thumbs = topicData.thumbs.filter(thumb => !toRemove.includes(thumb));
await Promise.all([ await Promise.all([
db.incrObjectFieldBy(`topic:${id}`, 'numThumbs', -toRemove.length), topics.setTopicFields(tid, {
thumbs: JSON.stringify(topicData.thumbs),
numThumbs: topicData.thumbs.length,
}),
Promise.all(toRemove.map(async relativePath => posts.uploads.dissociate(mainPid, relativePath))), Promise.all(toRemove.map(async relativePath => posts.uploads.dissociate(mainPid, relativePath))),
]); ]);
} }
if (toRemove.length) { };
cache.del(set);
Thumbs.deleteAll = async (tid) => {
const topicData = await topics.getTopicData(tid);
if (!topicData) {
return;
} }
}; await Thumbs.delete(tid, topicData.thumbs);
Thumbs.deleteAll = async (id) => {
const isDraft = !await topics.exists(id);
const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`;
const thumbs = await db.getSortedSetRange(set, 0, -1);
await Thumbs.delete(id, thumbs);
}; };

View File

@@ -0,0 +1,39 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Move post:<pid>:uploads to post hash',
timestamp: Date.UTC(2025, 6, 5),
method: async function () {
const { progress } = this;
const postCount = await db.sortedSetCard('posts:pid');
progress.total = postCount;
await batch.processSortedSet('posts:pid', async (pids) => {
const keys = pids.map(pid => `post:${pid}:uploads`);
const postUploadData = await db.getSortedSetsMembersWithScores(keys);
const bulkSet = [];
postUploadData.forEach((postUploads, idx) => {
const pid = pids[idx];
if (Array.isArray(postUploads) && postUploads.length > 0) {
bulkSet.push([
`post:${pid}`,
{ uploads: JSON.stringify(postUploads.map(upload => upload.value)) },
]);
}
});
await db.setObjectBulk(bulkSet);
await db.deleteAll(keys);
progress.incr(pids.length);
}, {
batch: 500,
});
},
};

View File

@@ -0,0 +1,39 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Move topic:<tid>:thumbs to topic hash',
timestamp: Date.UTC(2025, 6, 5),
method: async function () {
const { progress } = this;
const topicCount = await db.sortedSetCard('topics:tid');
progress.total = topicCount;
await batch.processSortedSet('topics:tid', async (tids) => {
const keys = tids.map(tid => `topic:${tid}:thumbs`);
const topicThumbData = await db.getSortedSetsMembersWithScores(keys);
const bulkSet = [];
topicThumbData.forEach((topicThumbs, idx) => {
const tid = tids[idx];
if (Array.isArray(topicThumbs) && topicThumbs.length > 0) {
bulkSet.push([
`topic:${tid}`,
{ thumbs: JSON.stringify(topicThumbs.map(thumb => thumb.value)) },
]);
}
});
await db.setObjectBulk(bulkSet);
await db.deleteAll(keys);
progress.incr(tids.length);
}, {
batch: 500,
});
},
};

View File

@@ -3,13 +3,13 @@
<div class="alert alert-info">[[modules:thumbs.modal.no-thumbs]]</div> <div class="alert alert-info">[[modules:thumbs.modal.no-thumbs]]</div>
{{{ end }}} {{{ end }}}
{{{ each thumbs }}} {{{ each thumbs }}}
<div class="d-flex align-items-center mb-3" data-id="{./id}" data-path="{./path}"> <div class="d-flex align-items-center mb-3" data-path="{@value}">
<div class="flex-shrink-0 py-2"> <div class="flex-shrink-0 py-2">
<img class="rounded" width="128px" style="height: auto;" src="{./url}" alt="" /> <img class="rounded" width="128px" style="height: auto;" src="{config.upload_url}{@value}" alt="" />
</div> </div>
<div class="flex-grow-1 ms-3"> <div class="flex-grow-1 ms-3">
<p> <p>
<code style="word-break: break-all;">{./name}</code> <code style="word-break: break-all;">{uploadBasename(@value)}</code>
</p> </p>
<button class="btn btn-danger btn-sm text-nowrap" data-action="remove"><i class="fa fa-times"></i> [[modules:thumbs.modal.remove]]</button> <button class="btn btn-danger btn-sm text-nowrap" data-action="remove"><i class="fa fa-times"></i> [[modules:thumbs.modal.remove]]</button>
</div> </div>

View File

@@ -62,13 +62,13 @@ describe('upload methods', () => {
}); });
describe('.sync()', () => { describe('.sync()', () => {
it('should properly add new images to the post\'s zset', (done) => { it('should properly add new images to the post\'s hash', (done) => {
posts.uploads.sync(pid, (err) => { posts.uploads.sync(pid, (err) => {
assert.ifError(err); assert.ifError(err);
db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { posts.uploads.list(pid, (err, uploads) => {
assert.ifError(err); assert.ifError(err);
assert.strictEqual(length, 2); assert.strictEqual(uploads.length, 2);
done(); done();
}); });
}); });
@@ -81,8 +81,8 @@ describe('upload methods', () => {
content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!', content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!',
}); });
await posts.uploads.sync(pid); await posts.uploads.sync(pid);
const length = await db.sortedSetCard(`post:${pid}:uploads`); const uploads = await posts.uploads.list(pid);
assert.strictEqual(1, length); assert.strictEqual(1, uploads.length);
}); });
}); });
@@ -345,13 +345,11 @@ describe('post uploads management', () => {
reply = replyData; reply = replyData;
}); });
it('should automatically sync uploads on topic create and reply', (done) => { it('should automatically sync uploads on topic create and reply', async () => {
db.sortedSetsCard([`post:${topic.topicData.mainPid}:uploads`, `post:${reply.pid}:uploads`], (err, lengths) => { const uploads1 = await posts.uploads.list(topic.topicData.mainPid);
assert.ifError(err); const uploads2 = await posts.uploads.list(reply.pid);
assert.strictEqual(lengths[0], 1); assert.strictEqual(uploads1.length, 1);
assert.strictEqual(lengths[1], 1); assert.strictEqual(uploads2.length, 1);
done();
});
}); });
it('should automatically sync uploads on post edit', async () => { it('should automatically sync uploads on post edit', async () => {

View File

@@ -37,8 +37,6 @@ describe('Topic thumbs', () => {
const relativeThumbPaths = thumbPaths.map(path => path.replace(nconf.get('upload_path'), '')); const relativeThumbPaths = thumbPaths.map(path => path.replace(nconf.get('upload_path'), ''));
const uuid = utils.generateUUID();
function createFiles() { function createFiles() {
fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[0]), 'w')); fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[0]), 'w'));
fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[1]), 'w')); fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[1]), 'w'));
@@ -70,7 +68,11 @@ describe('Topic thumbs', () => {
// Touch a couple files and associate it to a topic // Touch a couple files and associate it to a topic
createFiles(); createFiles();
await db.sortedSetAdd(`topic:${topicObj.topicData.tid}:thumbs`, 0, `${relativeThumbPaths[0]}`);
await topics.setTopicFields(topicObj.topicData.tid, {
numThumbs: 1,
thumbs: JSON.stringify([relativeThumbPaths[0]]),
});
}); });
it('should return bool for whether a thumb exists', async () => { it('should return bool for whether a thumb exists', async () => {
@@ -80,10 +82,9 @@ describe('Topic thumbs', () => {
describe('.get()', () => { describe('.get()', () => {
it('should return an array of thumbs', async () => { it('should return an array of thumbs', async () => {
require('../../src/cache').del(`topic:${topicObj.topicData.tid}:thumbs`);
const thumbs = await topics.thumbs.get(topicObj.topicData.tid); const thumbs = await topics.thumbs.get(topicObj.topicData.tid);
assert.deepStrictEqual(thumbs, [{ assert.deepStrictEqual(thumbs, [{
id: topicObj.topicData.tid, id: String(topicObj.topicData.tid),
name: 'test.png', name: 'test.png',
path: `${relativeThumbPaths[0]}`, path: `${relativeThumbPaths[0]}`,
url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`,
@@ -94,7 +95,7 @@ describe('Topic thumbs', () => {
const thumbs = await topics.thumbs.get([topicObj.topicData.tid, topicObj.topicData.tid + 1]); const thumbs = await topics.thumbs.get([topicObj.topicData.tid, topicObj.topicData.tid + 1]);
assert.deepStrictEqual(thumbs, [ assert.deepStrictEqual(thumbs, [
[{ [{
id: topicObj.topicData.tid, id: String(topicObj.topicData.tid),
name: 'test.png', name: 'test.png',
path: `${relativeThumbPaths[0]}`, path: `${relativeThumbPaths[0]}`,
url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`,
@@ -119,25 +120,13 @@ describe('Topic thumbs', () => {
mainPid = topicObj.postData.pid; mainPid = topicObj.postData.pid;
}); });
it('should add an uploaded file to a zset', async () => { it('should add an uploaded file to the topic hash', async () => {
await topics.thumbs.associate({ await topics.thumbs.associate({
id: tid, id: tid,
path: relativeThumbPaths[0], path: relativeThumbPaths[0],
}); });
const topicData = await topics.getTopicData(tid);
const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, relativeThumbPaths[0]); assert(topicData.thumbs.includes(relativeThumbPaths[0]));
assert(exists);
});
it('should also work with UUIDs', async () => {
await topics.thumbs.associate({
id: uuid,
path: relativeThumbPaths[1],
score: 5,
});
const exists = await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[1]);
assert(exists);
}); });
it('should also work with a URL', async () => { it('should also work with a URL', async () => {
@@ -145,14 +134,8 @@ describe('Topic thumbs', () => {
id: tid, id: tid,
path: relativeThumbPaths[2], path: relativeThumbPaths[2],
}); });
const topicData = await topics.getTopicData(tid);
const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, relativeThumbPaths[2]); assert(topicData.thumbs.includes(relativeThumbPaths[2]));
assert(exists);
});
it('should have a score equal to the number of thumbs prior to addition', async () => {
const scores = await db.sortedSetScores(`topic:${tid}:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[2]]);
assert.deepStrictEqual(scores, [0, 1]);
}); });
it('should update the relevant topic hash with the number of thumbnails', async () => { it('should update the relevant topic hash with the number of thumbnails', async () => {
@@ -166,23 +149,19 @@ describe('Topic thumbs', () => {
path: relativeThumbPaths[0], path: relativeThumbPaths[0],
}); });
const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]); const topicData = await topics.getTopicData(tid);
assert.strictEqual(topicData.thumbs.indexOf(relativeThumbPaths[0]), 1);
assert(isFinite(score)); // exists in set
assert.strictEqual(score, 2);
}); });
it('should update the score to be passed in as the third argument', async () => { it('should update the index to be passed in as the third argument', async () => {
await topics.thumbs.associate({ await topics.thumbs.associate({
id: tid, id: tid,
path: relativeThumbPaths[0], path: relativeThumbPaths[0],
score: 0, score: 0,
}); });
const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]); const topicData = await topics.getTopicData(tid);
assert.strictEqual(topicData.thumbs.indexOf(relativeThumbPaths[0]), 0);
assert(isFinite(score)); // exists in set
assert.strictEqual(score, 0);
}); });
it('should associate the thumbnail with that topic\'s main pid\'s uploads', async () => { it('should associate the thumbnail with that topic\'s main pid\'s uploads', async () => {
@@ -195,33 +174,6 @@ describe('Topic thumbs', () => {
const uploads = await posts.uploads.list(mainPid); const uploads = await posts.uploads.list(mainPid);
assert(uploads.includes(relativeThumbPaths[0])); assert(uploads.includes(relativeThumbPaths[0]));
}); });
it('should combine the thumbs uploaded to a UUID zset and combine it with a topic\'s thumb zset', async () => {
await topics.thumbs.migrate(uuid, tid);
const thumbs = await topics.thumbs.get(tid);
assert.strictEqual(thumbs.length, 3);
assert.deepStrictEqual(thumbs, [
{
id: tid,
name: 'test.png',
path: relativeThumbPaths[0],
url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`,
},
{
id: tid,
name: 'example.org',
path: 'https://example.org',
url: 'https://example.org',
},
{
id: tid,
name: 'test2.png',
path: relativeThumbPaths[1],
url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[1]}`,
},
]);
});
}); });
describe(`.delete()`, () => { describe(`.delete()`, () => {
@@ -231,8 +183,8 @@ describe('Topic thumbs', () => {
path: `/files/test.png`, path: `/files/test.png`,
}); });
await topics.thumbs.delete(1, `/files/test.png`); await topics.thumbs.delete(1, `/files/test.png`);
const thumbs = await topics.getTopicField(1, 'thumbs');
assert.strictEqual(await db.isSortedSetMember('topic:1:thumbs', '/files/test.png'), false); assert.strictEqual(thumbs.includes(`/files/test.png`), false);
}); });
it('should no longer be associated with that topic\'s main pid\'s uploads', async () => { it('should no longer be associated with that topic\'s main pid\'s uploads', async () => {
@@ -241,40 +193,12 @@ describe('Topic thumbs', () => {
assert(!uploads.includes(path.basename(relativeThumbPaths[0]))); assert(!uploads.includes(path.basename(relativeThumbPaths[0])));
}); });
it('should also work with UUIDs', async () => {
await topics.thumbs.associate({
id: uuid,
path: `/files/test.png`,
});
await topics.thumbs.delete(uuid, '/files/test.png');
assert.strictEqual(await db.isSortedSetMember(`draft:${uuid}:thumbs`, '/files/test.png'), false);
assert.strictEqual(await file.exists(path.join(`${nconf.get('upload_path')}`, '/files/test.png')), false);
});
it('should also work with URLs', async () => {
await topics.thumbs.associate({
id: uuid,
path: thumbPaths[2],
});
await topics.thumbs.delete(uuid, relativeThumbPaths[2]);
assert.strictEqual(await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[2]), false);
});
it('should not delete the file from disk if not associated with the tid', async () => {
createFiles();
await topics.thumbs.delete(uuid, thumbPaths[0]);
assert.strictEqual(await file.exists(thumbPaths[0]), true);
});
it('should have no more thumbs left', async () => { it('should have no more thumbs left', async () => {
const associated = await db.isSortedSetMembers(`topic:1:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[1]]); const thumbs = await topics.getTopicField(1, 'thumbs');
assert.strictEqual(associated.some(Boolean), false); assert.strictEqual(thumbs.length, 0);
}); });
it('should decrement numThumbs if dissociated one by one', async () => { it('should decrement numThumbs if dissociated one by one', async () => {
console.log('before', await db.getSortedSetRange(`topic:1:thumbs`, 0, -1));
await topics.thumbs.associate({ id: 1, path: `${nconf.get('relative_path')}${nconf.get('upload_url')}/files/test.png` }); await topics.thumbs.associate({ id: 1, path: `${nconf.get('relative_path')}${nconf.get('upload_url')}/files/test.png` });
await topics.thumbs.associate({ id: 1, path: `${nconf.get('relative_path')}${nconf.get('upload_url')}/files/test2.png` }); await topics.thumbs.associate({ id: 1, path: `${nconf.get('relative_path')}${nconf.get('upload_url')}/files/test2.png` });
@@ -290,18 +214,14 @@ describe('Topic thumbs', () => {
describe('.deleteAll()', () => { describe('.deleteAll()', () => {
before(async () => { before(async () => {
await Promise.all([ await topics.thumbs.associate({ id: 1, path: '/files/test.png' });
topics.thumbs.associate({ id: 1, path: '/files/test.png' }), await topics.thumbs.associate({ id: 1, path: '/files/test2.png' });
topics.thumbs.associate({ id: 1, path: '/files/test2.png' }),
]);
createFiles(); createFiles();
}); });
it('should have thumbs prior to tests', async () => { it('should have thumbs prior to tests', async () => {
const associated = await db.isSortedSetMembers( const thumbs = await topics.getTopicField(1, 'thumbs');
`topic:1:thumbs`, ['/files/test.png', '/files/test2.png'] assert.deepStrictEqual(thumbs, ['/files/test.png', '/files/test2.png']);
);
assert.strictEqual(associated.every(Boolean), true);
}); });
it('should not error out', async () => { it('should not error out', async () => {
@@ -309,14 +229,8 @@ describe('Topic thumbs', () => {
}); });
it('should remove all associated thumbs with that topic', async () => { it('should remove all associated thumbs with that topic', async () => {
const associated = await db.isSortedSetMembers( const thumbs = await topics.getTopicField(1, 'thumbs');
`topic:1:thumbs`, ['/files/test.png', '/files/test2.png'] assert.deepStrictEqual(thumbs, []);
);
assert.strictEqual(associated.some(Boolean), false);
});
it('should no longer have a :thumbs zset', async () => {
assert.strictEqual(await db.exists('topic:1:thumbs'), false);
}); });
}); });
@@ -330,11 +244,6 @@ describe('Topic thumbs', () => {
assert.strictEqual(response.statusCode, 200); assert.strictEqual(response.statusCode, 200);
}); });
it('should succeed with a uuid', async () => {
const { response } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF);
assert.strictEqual(response.statusCode, 200);
});
it('should succeed with uploader plugins', async () => { it('should succeed with uploader plugins', async () => {
const hookMethod = async () => ({ const hookMethod = async () => ({
name: 'test.png', name: 'test.png',
@@ -346,7 +255,7 @@ describe('Topic thumbs', () => {
}); });
const { response } = await helpers.uploadFile( const { response } = await helpers.uploadFile(
`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, `${nconf.get('url')}/api/v3/topics/1/thumbs`,
path.join(__dirname, '../files/test.png'), path.join(__dirname, '../files/test.png'),
{}, {},
adminJar, adminJar,
@@ -375,7 +284,7 @@ describe('Topic thumbs', () => {
it('should fail if thumbnails are not enabled', async () => { it('should fail if thumbnails are not enabled', async () => {
meta.config.allowTopicsThumbnail = 0; meta.config.allowTopicsThumbnail = 0;
const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF); const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/1/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF);
assert.strictEqual(response.statusCode, 503); assert.strictEqual(response.statusCode, 503);
assert(body && body.status); assert(body && body.status);
assert.strictEqual(body.status.message, 'Topic thumbnails are disabled.'); assert.strictEqual(body.status.message, 'Topic thumbnails are disabled.');
@@ -384,7 +293,7 @@ describe('Topic thumbs', () => {
it('should fail if file is not image', async () => { it('should fail if file is not image', async () => {
meta.config.allowTopicsThumbnail = 1; meta.config.allowTopicsThumbnail = 1;
const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/503.html'), {}, adminJar, adminCSRF); const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/1/thumbs`, path.join(__dirname, '../files/503.html'), {}, adminJar, adminCSRF);
assert.strictEqual(response.statusCode, 500); assert.strictEqual(response.statusCode, 500);
assert(body && body.status); assert(body && body.status);
assert.strictEqual(body.status.message, 'Invalid File'); assert.strictEqual(body.status.message, 'Invalid File');
@@ -402,21 +311,17 @@ describe('Topic thumbs', () => {
content: 'The content of test topic', content: 'The content of test topic',
}); });
await Promise.all([
topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[0] }), await topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[0] });
topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[1] }), await topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[1] });
]);
createFiles(); createFiles();
await topics.purge(topicObj.tid, adminUid); await topics.purge(topicObj.tid, adminUid);
}); });
it('should no longer have a :thumbs zset', async () => {
assert.strictEqual(await db.exists(`topic:${topicObj.tid}:thumbs`), false);
});
it('should not leave post upload associations behind', async () => { it('should not leave post upload associations behind', async () => {
const uploads = await db.getSortedSetMembers(`post:${topicObj.postData.pid}:uploads`); const uploads = await posts.uploads.list(topicObj.postData.pid);
assert.strictEqual(uploads.length, 0); assert.strictEqual(uploads.length, 0);
}); });
}); });