mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 08:36:12 +01:00
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:
@@ -97,7 +97,7 @@
|
||||
"multer": "2.0.1",
|
||||
"nconf": "0.13.0",
|
||||
"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-emoji": "6.0.3",
|
||||
"nodebb-plugin-emoji-android": "4.1.1",
|
||||
|
||||
@@ -300,6 +300,8 @@ PostDataObject:
|
||||
type: boolean
|
||||
attachments:
|
||||
type: array
|
||||
uploads:
|
||||
type: array
|
||||
replies:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -83,55 +83,6 @@ post:
|
||||
type: string
|
||||
name:
|
||||
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:
|
||||
tags:
|
||||
- topics
|
||||
|
||||
@@ -2,14 +2,14 @@ put:
|
||||
tags:
|
||||
- topics
|
||||
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:
|
||||
- in: path
|
||||
name: tid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid topic id or draft uuid
|
||||
description: a valid topic id
|
||||
example: 2
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
humanReadableNumber,
|
||||
formattedNumber,
|
||||
txEscape,
|
||||
uploadBasename,
|
||||
generatePlaceholderWave,
|
||||
register,
|
||||
__escape: identity,
|
||||
@@ -379,6 +380,12 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
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) {
|
||||
const html = items.map((i) => {
|
||||
if (i === 'divider') {
|
||||
|
||||
@@ -7,23 +7,27 @@ define('topicThumbs', [
|
||||
|
||||
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`, {
|
||||
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.get(id).then((thumbs) => {
|
||||
Promise.all(thumbs.map(thumb => Thumbs.delete(id, thumb.url)));
|
||||
});
|
||||
};
|
||||
|
||||
Thumbs.upload = id => new Promise((resolve) => {
|
||||
Thumbs.upload = () => new Promise((resolve) => {
|
||||
uploader.show({
|
||||
title: '[[topic:composer.thumb-title]]',
|
||||
method: 'put',
|
||||
route: config.relative_path + `/api/v3/topics/${id}/thumbs`,
|
||||
route: config.relative_path + `/api/topic/thumb/upload`,
|
||||
}, function (url) {
|
||||
resolve(url);
|
||||
});
|
||||
@@ -32,24 +36,16 @@ define('topicThumbs', [
|
||||
Thumbs.modal = {};
|
||||
|
||||
Thumbs.modal.open = function (payload) {
|
||||
const { id, pid } = payload;
|
||||
const { id, postData } = payload;
|
||||
let { modal } = payload;
|
||||
let numThumbs;
|
||||
const thumbs = postData.thumbs || [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Promise.all([
|
||||
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) => {
|
||||
Benchpress.render('modals/topic-thumbs', { thumbs }).then((html) => {
|
||||
if (modal) {
|
||||
translator.translate(html, function (translated) {
|
||||
modal.find('.bootbox-body').html(translated);
|
||||
Thumbs.modal.handleSort({ modal, numThumbs });
|
||||
Thumbs.modal.handleSort({ modal, thumbs });
|
||||
});
|
||||
} else {
|
||||
modal = bootbox.dialog({
|
||||
@@ -62,7 +58,11 @@ define('topicThumbs', [
|
||||
label: '<i class="fa fa-plus"></i> [[modules:thumbs.modal.add]]',
|
||||
className: 'btn-success',
|
||||
callback: () => {
|
||||
Thumbs.upload(id).then(() => {
|
||||
Thumbs.upload().then((thumbUrl) => {
|
||||
postData.thumbs.push(
|
||||
thumbUrl.replace(new RegExp(`^${config.upload_url}`), '')
|
||||
);
|
||||
|
||||
Thumbs.modal.open({ ...payload, modal });
|
||||
require(['composer'], (composer) => {
|
||||
composer.updateThumbCount(id, $(`[component="composer"][data-uuid="${id}"]`));
|
||||
@@ -79,7 +79,7 @@ define('topicThumbs', [
|
||||
},
|
||||
});
|
||||
Thumbs.modal.handleDelete({ ...payload, modal });
|
||||
Thumbs.modal.handleSort({ modal, numThumbs });
|
||||
Thumbs.modal.handleSort({ modal, thumbs });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -94,42 +94,42 @@ define('topicThumbs', [
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ev.target.closest('[data-id]').getAttribute('data-id');
|
||||
const path = ev.target.closest('[data-path]').getAttribute('data-path');
|
||||
api.del(`/topics/${id}/thumbs`, {
|
||||
path: path,
|
||||
}).then(() => {
|
||||
const postData = payload.postData;
|
||||
if (postData && postData.thumbs && postData.thumbs.includes(path)) {
|
||||
postData.thumbs = postData.thumbs.filter(thumb => thumb !== path);
|
||||
Thumbs.modal.open(payload);
|
||||
require(['composer'], (composer) => {
|
||||
composer.updateThumbCount(uuid, $(`[component="composer"][data-uuid="${uuid}"]`));
|
||||
});
|
||||
}).catch(alerts.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Thumbs.modal.handleSort = ({ modal, numThumbs }) => {
|
||||
if (numThumbs > 1) {
|
||||
Thumbs.modal.handleSort = ({ modal, thumbs }) => {
|
||||
if (thumbs.length > 1) {
|
||||
const selectorEl = modal.find('.topic-thumbs-modal');
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -715,8 +715,12 @@ Mocks.notes.public = async (post) => {
|
||||
|
||||
// Special handling for main posts (as:Article w/ as:Note preview)
|
||||
const noteAttachment = isMainPost ? [...attachment] : null;
|
||||
const uploads = await posts.uploads.listWithSizes(post.pid);
|
||||
const isThumb = await db.isSortedSetMembers(`topic:${post.tid}:thumbs`, uploads.map(u => u.name));
|
||||
const [uploads, thumbs] = await Promise.all([
|
||||
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) => {
|
||||
const mediaType = mime.getType(name);
|
||||
const url = `${nconf.get('url') + nconf.get('upload_url')}/${name}`;
|
||||
|
||||
@@ -120,9 +120,7 @@ postsAPI.edit = async function (caller, data) {
|
||||
data.timestamp = parseInt(data.timestamp, 10) || Date.now();
|
||||
|
||||
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);
|
||||
if (!selfPost && editResult.post.changed) {
|
||||
await events.log({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const validator = require('validator');
|
||||
|
||||
const user = require('../user');
|
||||
const topics = require('../topics');
|
||||
const categories = require('../categories');
|
||||
@@ -23,17 +21,13 @@ const socketHelpers = require('../socket.io/helpers');
|
||||
const topicsAPI = module.exports;
|
||||
|
||||
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
|
||||
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]]');
|
||||
}
|
||||
|
||||
// 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]]');
|
||||
}
|
||||
};
|
||||
@@ -80,7 +74,6 @@ topicsAPI.create = async function (caller, data) {
|
||||
}
|
||||
|
||||
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_topic', result.topicData, [caller.uid]);
|
||||
@@ -233,17 +226,6 @@ topicsAPI.getThumbs = async (caller, { 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 }) => {
|
||||
await topicsAPI._checkThumbPrivileges({ tid: tid, uid: caller.uid });
|
||||
await topics.thumbs.delete(tid, path);
|
||||
|
||||
@@ -138,25 +138,18 @@ Topics.addThumb = async (req, res) => {
|
||||
|
||||
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) {
|
||||
await Promise.all(files.map(async (fileObj) => {
|
||||
for (const fileObj of files) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await topics.thumbs.associate({
|
||||
id: req.params.tid,
|
||||
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) => {
|
||||
if (!req.body.path.startsWith('http')) {
|
||||
|
||||
@@ -70,5 +70,13 @@ function modifyPost(post, fields) {
|
||||
if (!fields.length || fields.includes('attachments')) {
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ module.exports = function (Posts) {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -142,6 +142,15 @@ module.exports = function (Posts) {
|
||||
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', {
|
||||
req: data.req,
|
||||
topic: newTopicData,
|
||||
@@ -172,6 +181,7 @@ module.exports = function (Posts) {
|
||||
renamed: renamed,
|
||||
tagsupdated: tagsupdated,
|
||||
tags: tags,
|
||||
thumbsupdated: thumbsupdated,
|
||||
oldTags: topicData.tags,
|
||||
rescheduled: rescheduling(data, topicData),
|
||||
};
|
||||
|
||||
@@ -46,12 +46,14 @@ module.exports = function (Posts) {
|
||||
Posts.uploads.sync = async function (pid) {
|
||||
// Scans a post's content and updates sorted set of uploads
|
||||
|
||||
const [content, currentUploads, isMainPost] = await Promise.all([
|
||||
Posts.getPostField(pid, 'content'),
|
||||
Posts.uploads.list(pid),
|
||||
const [postData, isMainPost] = await Promise.all([
|
||||
Posts.getPostFields(pid, ['content', 'uploads']),
|
||||
Posts.isMain(pid),
|
||||
]);
|
||||
|
||||
const content = postData.content || '';
|
||||
const currentUploads = postData.uploads || [];
|
||||
|
||||
// Extract upload file paths from post content
|
||||
let match = searchRegex.exec(content);
|
||||
let uploads = new Set();
|
||||
@@ -75,14 +77,19 @@ module.exports = function (Posts) {
|
||||
// Create add/remove sets
|
||||
const add = uploads.filter(path => !currentUploads.includes(path));
|
||||
const remove = currentUploads.filter(path => !uploads.includes(path));
|
||||
await Promise.all([
|
||||
Posts.uploads.associate(pid, add),
|
||||
Posts.uploads.dissociate(pid, remove),
|
||||
]);
|
||||
await Posts.uploads.associate(pid, add);
|
||||
await Posts.uploads.dissociate(pid, remove);
|
||||
};
|
||||
|
||||
Posts.uploads.list = async function (pid) {
|
||||
return await db.getSortedSetMembers(`post:${pid}:uploads`);
|
||||
Posts.uploads.list = async function (pids) {
|
||||
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) {
|
||||
@@ -157,33 +164,38 @@ module.exports = function (Posts) {
|
||||
};
|
||||
|
||||
Posts.uploads.associate = async function (pid, filePaths) {
|
||||
// Adds an upload to a post's sorted set of uploads
|
||||
filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths;
|
||||
if (!filePaths.length) {
|
||||
return;
|
||||
}
|
||||
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 scores = filePaths.map((p, i) => now + i);
|
||||
const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]);
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetAdd(`post:${pid}:uploads`, scores, filePaths),
|
||||
db.setObjectField(`post:${pid}`, 'uploads', JSON.stringify(currentUploads)),
|
||||
db.sortedSetAddBulk(bulkAdd),
|
||||
Posts.uploads.saveSize(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;
|
||||
if (!filePaths.length) {
|
||||
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 promises = [
|
||||
db.sortedSetRemove(`post:${pid}:uploads`, filePaths),
|
||||
db.setObjectField(`post:${pid}`, 'uploads', JSON.stringify(currentUploads)),
|
||||
db.sortedSetRemoveBulk(bulkRemove),
|
||||
];
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ module.exports = function (app, middleware, controllers) {
|
||||
];
|
||||
|
||||
router.post('/post/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadPost));
|
||||
router.post('/topic/thumb/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadThumb));
|
||||
router.post('/user/:userslug/uploadpicture', [
|
||||
...middlewares,
|
||||
...postMiddlewares,
|
||||
|
||||
@@ -41,7 +41,7 @@ module.exports = function () {
|
||||
...middlewares,
|
||||
], 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, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs);
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ module.exports = function (Topics) {
|
||||
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 });
|
||||
topicData = result.topic;
|
||||
await db.setObject(`topic:${topicData.tid}`, topicData);
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,29 +5,26 @@ const _ = require('lodash');
|
||||
const nconf = require('nconf');
|
||||
const path = require('path');
|
||||
const mime = require('mime');
|
||||
|
||||
const db = require('../database');
|
||||
const file = require('../file');
|
||||
const plugins = require('../plugins');
|
||||
const posts = require('../posts');
|
||||
const meta = require('../meta');
|
||||
const cache = require('../cache');
|
||||
|
||||
const topics = module.parent.exports;
|
||||
const Thumbs = module.exports;
|
||||
|
||||
Thumbs.exists = async function (id, path) {
|
||||
const isDraft = !await topics.exists(id);
|
||||
const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`;
|
||||
const upload_url = nconf.get('relative_path') + nconf.get('upload_url');
|
||||
const upload_path = nconf.get('upload_path');
|
||||
|
||||
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) {
|
||||
const mainPids = topicData.filter(Boolean).map(t => t.mainPid);
|
||||
let hashes = await posts.getPostsFields(mainPids, ['attachments']);
|
||||
const hasUploads = await db.exists(mainPids.map(pid => `post:${pid}:uploads`));
|
||||
hashes = hashes.map(o => o.attachments);
|
||||
const mainPostData = await posts.getPostsFields(mainPids, ['attachments', 'uploads']);
|
||||
const hasUploads = mainPostData.map(p => Array.isArray(p.uploads) && p.uploads.length > 0);
|
||||
const hashes = mainPostData.map(o => o.attachments);
|
||||
let hasThumbs = topicData.map((t, idx) => t &&
|
||||
(parseInt(t.numThumbs, 10) > 0 ||
|
||||
!!(hashes[idx] && hashes[idx].length) ||
|
||||
@@ -36,11 +33,70 @@ Thumbs.load = async function (topicData) {
|
||||
|
||||
const topicsWithThumbs = topicData.filter((tid, idx) => hasThumbs[idx]);
|
||||
const tidsWithThumbs = topicsWithThumbs.map(t => t.tid);
|
||||
const thumbs = await Thumbs.get(tidsWithThumbs);
|
||||
|
||||
const thumbs = await loadFromTopicData(topicsWithThumbs);
|
||||
|
||||
const tidToThumbs = _.zipObject(tidsWithThumbs, thumbs);
|
||||
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) {
|
||||
// Allow singular or plural usage
|
||||
let singular = false;
|
||||
@@ -54,118 +110,77 @@ Thumbs.get = async function (tids, options) {
|
||||
thumbsOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isDraft = (await topics.exists(tids)).map(exists => !exists);
|
||||
|
||||
if (!meta.config.allowTopicsThumbnail || !tids.length) {
|
||||
return singular ? [] : tids.map(() => []);
|
||||
}
|
||||
|
||||
const hasTimestampPrefix = /^\d+-/;
|
||||
const upload_url = nconf.get('relative_path') + nconf.get('upload_url');
|
||||
const sets = tids.map((tid, idx) => `${isDraft[idx] ? 'draft' : 'topic'}:${tid}:thumbs`);
|
||||
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;
|
||||
const topicData = await topics.getTopicsFields(tids, ['tid', 'mainPid', 'thumbs']);
|
||||
const response = await loadFromTopicData(topicData, options);
|
||||
return singular ? response[0] : 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 }) {
|
||||
// Associates a newly uploaded file as a thumb to the passed-in draft or topic
|
||||
const isDraft = !await topics.exists(id);
|
||||
// Associates a newly uploaded file as a thumb to the passed-in topic
|
||||
const topicData = await topics.getTopicData(id);
|
||||
if (!topicData) {
|
||||
return;
|
||||
}
|
||||
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)
|
||||
if (isLocal) {
|
||||
path = path.replace(nconf.get('relative_path'), '');
|
||||
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 (!isDraft && isLocal) {
|
||||
const mainPid = (await topics.getMainPids([id]))[0];
|
||||
await posts.uploads.associate(mainPid, path);
|
||||
if (Array.isArray(topicData.thumbs)) {
|
||||
const currentIdx = topicData.thumbs.indexOf(path);
|
||||
const insertIndex = (typeof score === 'number' && score >= 0 && score < topicData.thumbs.length) ?
|
||||
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) {
|
||||
// Converts the draft thumb zset to the topic zset (combines thumbs if applicable)
|
||||
const set = `draft:${uuid}:thumbs`;
|
||||
const thumbs = await db.getSortedSetRangeWithScores(set, 0, -1);
|
||||
await Promise.all(thumbs.map(async thumb => await Thumbs.associate({
|
||||
id,
|
||||
path: thumb.value,
|
||||
score: thumb.score,
|
||||
})));
|
||||
await db.delete(set);
|
||||
cache.del(set);
|
||||
Thumbs.filterThumbs = function (thumbs) {
|
||||
if (!Array.isArray(thumbs)) {
|
||||
return [];
|
||||
}
|
||||
thumbs = thumbs.filter((thumb) => {
|
||||
if (thumb.startsWith('http')) {
|
||||
return true;
|
||||
}
|
||||
// ensure it is in upload path
|
||||
const fullPath = path.join(upload_path, thumb);
|
||||
return fullPath.startsWith(upload_path);
|
||||
});
|
||||
return thumbs;
|
||||
};
|
||||
|
||||
Thumbs.delete = async function (id, relativePaths) {
|
||||
const isDraft = !await topics.exists(id);
|
||||
const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`;
|
||||
Thumbs.delete = async function (tid, relativePaths) {
|
||||
const topicData = await topics.getTopicData(tid);
|
||||
if (!topicData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof relativePaths === 'string') {
|
||||
relativePaths = [relativePaths];
|
||||
@@ -173,48 +188,28 @@ Thumbs.delete = async function (id, relativePaths) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const absolutePaths = relativePaths.map(relativePath => path.join(nconf.get('upload_path'), relativePath));
|
||||
const [associated, existsOnDisk] = await Promise.all([
|
||||
db.isSortedSetMembers(set, relativePaths),
|
||||
Promise.all(absolutePaths.map(async absolutePath => file.exists(absolutePath))),
|
||||
]);
|
||||
const toRemove = relativePaths.map(
|
||||
relativePath => topicData.thumbs.includes(relativePath) ? relativePath : null
|
||||
).filter(Boolean);
|
||||
|
||||
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([
|
||||
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))),
|
||||
]);
|
||||
}
|
||||
if (toRemove.length) {
|
||||
cache.del(set);
|
||||
};
|
||||
|
||||
Thumbs.deleteAll = async (tid) => {
|
||||
const topicData = await topics.getTopicData(tid);
|
||||
if (!topicData) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
await Thumbs.delete(tid, topicData.thumbs);
|
||||
};
|
||||
|
||||
39
src/upgrades/4.5.0/post-uploads-to-hash.js
Normal file
39
src/upgrades/4.5.0/post-uploads-to-hash.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
39
src/upgrades/4.5.0/topic-thumbs-to-hash.js
Normal file
39
src/upgrades/4.5.0/topic-thumbs-to-hash.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="alert alert-info">[[modules:thumbs.modal.no-thumbs]]</div>
|
||||
{{{ end }}}
|
||||
{{{ 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">
|
||||
<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 class="flex-grow-1 ms-3">
|
||||
<p>
|
||||
<code style="word-break: break-all;">{./name}</code>
|
||||
<code style="word-break: break-all;">{uploadBasename(@value)}</code>
|
||||
</p>
|
||||
<button class="btn btn-danger btn-sm text-nowrap" data-action="remove"><i class="fa fa-times"></i> [[modules:thumbs.modal.remove]]</button>
|
||||
</div>
|
||||
|
||||
@@ -62,13 +62,13 @@ describe('upload methods', () => {
|
||||
});
|
||||
|
||||
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) => {
|
||||
assert.ifError(err);
|
||||
|
||||
db.sortedSetCard(`post:${pid}:uploads`, (err, length) => {
|
||||
posts.uploads.list(pid, (err, uploads) => {
|
||||
assert.ifError(err);
|
||||
assert.strictEqual(length, 2);
|
||||
assert.strictEqual(uploads.length, 2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,8 @@ describe('upload methods', () => {
|
||||
content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!',
|
||||
});
|
||||
await posts.uploads.sync(pid);
|
||||
const length = await db.sortedSetCard(`post:${pid}:uploads`);
|
||||
assert.strictEqual(1, length);
|
||||
const uploads = await posts.uploads.list(pid);
|
||||
assert.strictEqual(1, uploads.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,13 +345,11 @@ describe('post uploads management', () => {
|
||||
reply = replyData;
|
||||
});
|
||||
|
||||
it('should automatically sync uploads on topic create and reply', (done) => {
|
||||
db.sortedSetsCard([`post:${topic.topicData.mainPid}:uploads`, `post:${reply.pid}:uploads`], (err, lengths) => {
|
||||
assert.ifError(err);
|
||||
assert.strictEqual(lengths[0], 1);
|
||||
assert.strictEqual(lengths[1], 1);
|
||||
done();
|
||||
});
|
||||
it('should automatically sync uploads on topic create and reply', async () => {
|
||||
const uploads1 = await posts.uploads.list(topic.topicData.mainPid);
|
||||
const uploads2 = await posts.uploads.list(reply.pid);
|
||||
assert.strictEqual(uploads1.length, 1);
|
||||
assert.strictEqual(uploads2.length, 1);
|
||||
});
|
||||
|
||||
it('should automatically sync uploads on post edit', async () => {
|
||||
|
||||
@@ -37,8 +37,6 @@ describe('Topic thumbs', () => {
|
||||
|
||||
const relativeThumbPaths = thumbPaths.map(path => path.replace(nconf.get('upload_path'), ''));
|
||||
|
||||
const uuid = utils.generateUUID();
|
||||
|
||||
function createFiles() {
|
||||
fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[0]), '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
|
||||
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 () => {
|
||||
@@ -80,10 +82,9 @@ describe('Topic thumbs', () => {
|
||||
|
||||
describe('.get()', () => {
|
||||
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);
|
||||
assert.deepStrictEqual(thumbs, [{
|
||||
id: topicObj.topicData.tid,
|
||||
id: String(topicObj.topicData.tid),
|
||||
name: 'test.png',
|
||||
path: `${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]);
|
||||
assert.deepStrictEqual(thumbs, [
|
||||
[{
|
||||
id: topicObj.topicData.tid,
|
||||
id: String(topicObj.topicData.tid),
|
||||
name: 'test.png',
|
||||
path: `${relativeThumbPaths[0]}`,
|
||||
url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`,
|
||||
@@ -119,25 +120,13 @@ describe('Topic thumbs', () => {
|
||||
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({
|
||||
id: tid,
|
||||
path: relativeThumbPaths[0],
|
||||
});
|
||||
|
||||
const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, 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);
|
||||
const topicData = await topics.getTopicData(tid);
|
||||
assert(topicData.thumbs.includes(relativeThumbPaths[0]));
|
||||
});
|
||||
|
||||
it('should also work with a URL', async () => {
|
||||
@@ -145,14 +134,8 @@ describe('Topic thumbs', () => {
|
||||
id: tid,
|
||||
path: relativeThumbPaths[2],
|
||||
});
|
||||
|
||||
const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, 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]);
|
||||
const topicData = await topics.getTopicData(tid);
|
||||
assert(topicData.thumbs.includes(relativeThumbPaths[2]));
|
||||
});
|
||||
|
||||
it('should update the relevant topic hash with the number of thumbnails', async () => {
|
||||
@@ -166,23 +149,19 @@ describe('Topic thumbs', () => {
|
||||
path: relativeThumbPaths[0],
|
||||
});
|
||||
|
||||
const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]);
|
||||
|
||||
assert(isFinite(score)); // exists in set
|
||||
assert.strictEqual(score, 2);
|
||||
const topicData = await topics.getTopicData(tid);
|
||||
assert.strictEqual(topicData.thumbs.indexOf(relativeThumbPaths[0]), 1);
|
||||
});
|
||||
|
||||
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({
|
||||
id: tid,
|
||||
path: relativeThumbPaths[0],
|
||||
score: 0,
|
||||
});
|
||||
|
||||
const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]);
|
||||
|
||||
assert(isFinite(score)); // exists in set
|
||||
assert.strictEqual(score, 0);
|
||||
const topicData = await topics.getTopicData(tid);
|
||||
assert.strictEqual(topicData.thumbs.indexOf(relativeThumbPaths[0]), 0);
|
||||
});
|
||||
|
||||
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);
|
||||
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()`, () => {
|
||||
@@ -231,8 +183,8 @@ describe('Topic thumbs', () => {
|
||||
path: `/files/test.png`,
|
||||
});
|
||||
await topics.thumbs.delete(1, `/files/test.png`);
|
||||
|
||||
assert.strictEqual(await db.isSortedSetMember('topic:1:thumbs', '/files/test.png'), false);
|
||||
const thumbs = await topics.getTopicField(1, 'thumbs');
|
||||
assert.strictEqual(thumbs.includes(`/files/test.png`), false);
|
||||
});
|
||||
|
||||
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])));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const associated = await db.isSortedSetMembers(`topic:1:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[1]]);
|
||||
assert.strictEqual(associated.some(Boolean), false);
|
||||
const thumbs = await topics.getTopicField(1, 'thumbs');
|
||||
assert.strictEqual(thumbs.length, 0);
|
||||
});
|
||||
|
||||
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/test2.png` });
|
||||
|
||||
@@ -290,18 +214,14 @@ describe('Topic thumbs', () => {
|
||||
|
||||
describe('.deleteAll()', () => {
|
||||
before(async () => {
|
||||
await Promise.all([
|
||||
topics.thumbs.associate({ id: 1, path: '/files/test.png' }),
|
||||
topics.thumbs.associate({ id: 1, path: '/files/test2.png' }),
|
||||
]);
|
||||
await topics.thumbs.associate({ id: 1, path: '/files/test.png' });
|
||||
await topics.thumbs.associate({ id: 1, path: '/files/test2.png' });
|
||||
createFiles();
|
||||
});
|
||||
|
||||
it('should have thumbs prior to tests', async () => {
|
||||
const associated = await db.isSortedSetMembers(
|
||||
`topic:1:thumbs`, ['/files/test.png', '/files/test2.png']
|
||||
);
|
||||
assert.strictEqual(associated.every(Boolean), true);
|
||||
const thumbs = await topics.getTopicField(1, 'thumbs');
|
||||
assert.deepStrictEqual(thumbs, ['/files/test.png', '/files/test2.png']);
|
||||
});
|
||||
|
||||
it('should not error out', async () => {
|
||||
@@ -309,14 +229,8 @@ describe('Topic thumbs', () => {
|
||||
});
|
||||
|
||||
it('should remove all associated thumbs with that topic', async () => {
|
||||
const associated = await db.isSortedSetMembers(
|
||||
`topic:1:thumbs`, ['/files/test.png', '/files/test2.png']
|
||||
);
|
||||
assert.strictEqual(associated.some(Boolean), false);
|
||||
});
|
||||
|
||||
it('should no longer have a :thumbs zset', async () => {
|
||||
assert.strictEqual(await db.exists('topic:1:thumbs'), false);
|
||||
const thumbs = await topics.getTopicField(1, 'thumbs');
|
||||
assert.deepStrictEqual(thumbs, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,11 +244,6 @@ describe('Topic thumbs', () => {
|
||||
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 () => {
|
||||
const hookMethod = async () => ({
|
||||
name: 'test.png',
|
||||
@@ -346,7 +255,7 @@ describe('Topic thumbs', () => {
|
||||
});
|
||||
|
||||
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'),
|
||||
{},
|
||||
adminJar,
|
||||
@@ -375,7 +284,7 @@ describe('Topic thumbs', () => {
|
||||
it('should fail if thumbnails are not enabled', async () => {
|
||||
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(body && body.status);
|
||||
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 () => {
|
||||
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(body && body.status);
|
||||
assert.strictEqual(body.status.message, 'Invalid File');
|
||||
@@ -402,21 +311,17 @@ describe('Topic thumbs', () => {
|
||||
content: 'The content of test topic',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
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[0] });
|
||||
await topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[1] });
|
||||
|
||||
createFiles();
|
||||
|
||||
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 () => {
|
||||
const uploads = await db.getSortedSetMembers(`post:${topicObj.postData.pid}:uploads`);
|
||||
const uploads = await posts.uploads.list(topicObj.postData.pid);
|
||||
assert.strictEqual(uploads.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user