mirror of
				https://github.com/NodeBB/NodeBB.git
				synced 2025-10-31 11:05:54 +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", |         "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", | ||||||
|   | |||||||
| @@ -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: | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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)); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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') { | ||||||
|   | |||||||
| @@ -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,41 +94,41 @@ 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', Thumbs.modal.handleSortChange); | 			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); | ||||||
| 					} | 					} | ||||||
| 	}; |  | ||||||
|  |  | ||||||
| 	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); |  | ||||||
| 				}); | 				}); | ||||||
|  | 				// Mutate thumbs array in place | ||||||
|  | 				thumbs.length = 0; | ||||||
|  | 				Array.prototype.push.apply(thumbs, newOrder); | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	return Thumbs; | 	return Thumbs; | ||||||
|   | |||||||
| @@ -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}`; | ||||||
|   | |||||||
| @@ -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({ | ||||||
|   | |||||||
| @@ -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); | ||||||
|   | |||||||
| @@ -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')) { | ||||||
|   | |||||||
| @@ -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 = []; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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), | ||||||
| 		}; | 		}; | ||||||
|   | |||||||
| @@ -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), | ||||||
| 		]; | 		]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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); | ||||||
|   | |||||||
| @@ -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 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); |  | ||||||
|  |  | ||||||
|  | 	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) | 		// Associate thumbnails with the main pid (only on local upload) | ||||||
| 	if (!isDraft && isLocal) { | 		if (isLocal && currentIdx === -1) { | ||||||
| 		const mainPid = (await topics.getMainPids([id]))[0]; | 			await posts.uploads.associate(topicData.mainPid, path); | ||||||
| 		await posts.uploads.associate(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); |  | ||||||
| }; | }; | ||||||
|   | |||||||
							
								
								
									
										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> | 	<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> | ||||||
|   | |||||||
| @@ -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 () => { | ||||||
|   | |||||||
| @@ -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); | ||||||
| 		}); | 		}); | ||||||
| 	}); | 	}); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user