Files
NodeBB/src/posts/uploads.js

253 lines
8.1 KiB
JavaScript
Raw Normal View History

2018-04-13 16:12:11 -04:00
'use strict';
const nconf = require('nconf');
const fs = require('fs').promises;
const crypto = require('crypto');
const path = require('path');
const winston = require('winston');
const mime = require('mime');
const validator = require('validator');
const cronJob = require('cron').CronJob;
const chalk = require('chalk');
const db = require('../database');
const image = require('../image');
const user = require('../user');
const topics = require('../topics');
const file = require('../file');
const meta = require('../meta');
2018-04-13 16:12:11 -04:00
module.exports = function (Posts) {
Posts.uploads = {};
const md5 = filename => crypto.createHash('md5').update(filename).digest('hex');
const pathPrefix = path.join(nconf.get('upload_path'));
const searchRegex = /\/assets\/uploads(\/files\/[^\s")]+\.?[\w]*)/g;
const _getFullPath = relativePath => path.join(pathPrefix, relativePath);
const _filterValidPaths = async filePaths => (await Promise.all(filePaths.map(async (filePath) => {
const fullPath = _getFullPath(filePath);
return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false;
}))).filter(Boolean);
const runJobs = nconf.get('runJobs');
if (runJobs) {
new cronJob('0 2 * * 0', async () => {
const orphans = await Posts.uploads.cleanOrphans();
if (orphans.length) {
winston.info(`[posts/uploads] Deleting ${orphans.length} orphaned uploads...`);
orphans.forEach((relPath) => {
process.stdout.write(`${chalk.red(' - ')} ${relPath}`);
});
}
}, null, true);
}
2019-07-17 19:05:55 -04:00
Posts.uploads.sync = async function (pid) {
// Scans a post's content and updates sorted set of uploads
2018-04-13 16:12:11 -04:00
const [postData, isMainPost] = await Promise.all([
Posts.getPostFields(pid, ['content', 'uploads']),
Posts.isMain(pid),
2019-07-17 19:05:55 -04:00
]);
2018-04-13 16:12:11 -04:00
const content = postData.content || '';
const currentUploads = postData.uploads || [];
2019-07-17 19:05:55 -04:00
// Extract upload file paths from post content
let match = searchRegex.exec(content);
2025-01-08 12:06:56 -05:00
let uploads = new Set();
2019-07-17 19:05:55 -04:00
while (match) {
2025-01-08 12:06:56 -05:00
uploads.add(match[1].replace('-resized', ''));
2019-07-17 19:05:55 -04:00
match = searchRegex.exec(content);
}
2018-04-13 16:12:11 -04:00
// Main posts can contain topic thumbs, which are also tracked by pid
if (isMainPost) {
const tid = await Posts.getPostField(pid, 'tid');
2025-01-08 12:06:56 -05:00
let thumbs = await topics.thumbs.get(tid, { thumbsOnly: true });
thumbs = thumbs.map(thumb => thumb.path).filter(path => !validator.isURL(path, {
require_protocol: true,
}));
2025-01-08 12:06:56 -05:00
thumbs.forEach(t => uploads.add(t));
}
2025-01-08 12:06:56 -05:00
uploads = Array.from(uploads);
2019-07-17 19:05:55 -04:00
// Create add/remove sets
const add = uploads.filter(path => !currentUploads.includes(path));
const remove = currentUploads.filter(path => !uploads.includes(path));
await Posts.uploads.associate(pid, add);
await Posts.uploads.dissociate(pid, remove);
2018-04-13 16:12:11 -04:00
};
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;
2018-04-13 16:12:11 -04:00
};
Posts.uploads.listWithSizes = async function (pid) {
Async refactor in place (#7736) * feat: allow both callback&and await * feat: ignore async key * feat: callbackify and promisify in same file * Revert "feat: callbackify and promisify in same file" This reverts commit cea206a9b8e6d8295310074b18cc82a504487862. * feat: no need to store .callbackify * feat: change getTopics to async * feat: remove .async * fix: byScore * feat: rewrite topics/index and social with async/await * fix: rewrite topics/data.js fix issue with async.waterfall, only pass result if its not undefined * feat: add callbackify to redis/psql * feat: psql use await * fix: redis :volcano: * feat: less returns * feat: more await rewrite * fix: redis tests * feat: convert sortedSetAdd rewrite psql transaction to async/await * feat: :dog: * feat: test * feat: log client and query * feat: log bind * feat: more logs * feat: more logs * feat: check perform * feat: dont callbackify transaction * feat: remove logs * fix: main functions * feat: more logs * fix: increment * fix: rename * feat: remove cls * fix: remove console.log * feat: add deprecation message to .async usage * feat: update more dbal methods * fix: redis :voodoo: * feat: fix redis zrem, convert setObject * feat: upgrade getObject methods * fix: psql getObjectField * fix: redis tests * feat: getObjectKeys * feat: getObjectValues * feat: isObjectField * fix: add missing return * feat: delObjectField * feat: incrObjectField * fix: add missing await * feat: remove exposed helpers * feat: list methods * feat: flush/empty * feat: delete * fix: redis delete all * feat: get/set * feat: incr/rename * feat: type * feat: expire * feat: setAdd * feat: setRemove * feat: isSetMember * feat: getSetMembers * feat: setCount, setRemoveRandom * feat: zcard,zcount * feat: sortedSetRank * feat: isSortedSetMember * feat: zincrby * feat: sortedSetLex * feat: processSortedSet * fix: add mising await * feat: debug psql * fix: psql test * fix: test * fix: another test * fix: test fix * fix: psql tests * feat: remove logs * feat: user arrow func use builtin async promises * feat: topic bookmarks * feat: topic.delete * feat: topic.restore * feat: topics.purge * feat: merge * feat: suggested * feat: topics/user.js * feat: topics modules * feat: topics/follow * fix: deprecation msg * feat: fork * feat: topics/posts * feat: sorted/recent * feat: topic/teaser * feat: topics/tools * feat: topics/unread * feat: add back node versions disable deprecation notice wrap async controllers in try/catch * feat: use db directly * feat: promisify in place * fix: redis/psql * feat: deprecation message logs for psql * feat: more logs * feat: more logs * feat: logs again * feat: more logs * fix: call release * feat: restore travis, remove logs * fix: loops * feat: remove .async. usage
2019-07-09 12:46:49 -04:00
const paths = await Posts.uploads.list(pid);
2021-02-03 23:59:08 -07:00
const sizes = await db.getObjects(paths.map(path => `upload:${md5(path)}`)) || [];
2019-03-27 23:52:13 -04:00
return sizes.map((sizeObj, idx) => ({
...sizeObj,
name: paths[idx],
}));
};
Posts.uploads.getOrphans = async () => {
let files = await fs.readdir(_getFullPath('/files'));
files = files.filter(filename => filename !== '.gitignore');
// Exclude non-timestamped files (e.g. group covers; see gh#10783/gh#10705)
2022-07-30 19:57:48 -04:00
const tsPrefix = /^\d{13}-/;
files = files.filter(filename => tsPrefix.test(filename));
2025-04-04 11:47:24 -04:00
files = await Promise.all(files.map(
async filename => (await Posts.uploads.isOrphan(`/files/${filename}`) ? `/files/${filename}` : null)
));
files = files.filter(Boolean);
return files;
};
Posts.uploads.cleanOrphans = async () => {
const now = Date.now();
const expiration = now - (1000 * 60 * 60 * 24 * meta.config.orphanExpiryDays);
const days = meta.config.orphanExpiryDays;
if (!days) {
return [];
}
let orphans = await Posts.uploads.getOrphans();
orphans = await Promise.all(orphans.map(async (relPath) => {
const { mtimeMs } = await fs.stat(_getFullPath(relPath));
return mtimeMs < expiration ? relPath : null;
}));
orphans = orphans.filter(Boolean);
2023-05-22 12:02:34 -04:00
await Promise.all(orphans.map(async (relPath) => {
await file.delete(_getFullPath(relPath));
}));
return orphans;
};
2019-07-17 19:05:55 -04:00
Posts.uploads.isOrphan = async function (filePath) {
2021-02-03 23:59:08 -07:00
const length = await db.sortedSetCard(`upload:${md5(filePath)}:pids`);
2019-07-17 19:05:55 -04:00
return length === 0;
2018-04-16 16:44:17 -04:00
};
2019-07-17 19:05:55 -04:00
Posts.uploads.getUsage = async function (filePaths) {
// Given an array of file names, determines which pids they are used in
if (!Array.isArray(filePaths)) {
filePaths = [filePaths];
}
// windows path => 'files\\1685368788211-1-profileimg.jpg'
// linux path => files/1685368788211-1-profileimg.jpg
// turn them into => '/files/1685368788211-1-profileimg.jpg'
filePaths.forEach((file) => {
file.path = `/${file.path.split(path.sep).join(path.posix.sep)}`;
});
Acp redesign (#11639) * acp sidebar * gap in nav * remove shadow * label fixes * color fixes * feat: settings page wip * feat: scroll spy :eyeglasses: move social into general, store social in meta.config like other settings write upgrade script * remove social * rermove openapi routes * cleanup, highlight selected nav item * more cleanup * advanced margin top * derp * match design * bring back version alert fix homepage js, since it moved to general settings * remove unused tpls these moved to general settings * remove more css * offcanvas for mobile fix search * add timeout * add new props * manage categories * small fixes * category-edit * feat category page fixes * add title to settings pages add user settings page * small fixes * some more settings pages * fix: plugin page titles * more settings pages * more padding * more pages, add acp paginator.tpl so it doesn't change when active theme changes * remove placeholder * dashboard table * fix: openapi * fix: controller tests * use fonts from core * some small fixes * fix rep page * refactor: fix name of upgrade script * create category modal group edit * group/groups pages * admins mods * privs * uploads * missing margin * more acp pages * more pages * plugins/rewards/widgets * wrap rewards * fix widgets * fix widget clone button * fix group acp edit link * update search dropdown * remove display block from tbody * use less css * remove some derp links * remove striped tables * remove p tags from lang files * update email settings * Update api.tpl * move tag-whitelist
2023-05-31 11:54:48 -04:00
const keys = filePaths.map(fileObj => `upload:${md5(fileObj.path.replace('-resized', ''))}:pids`);
2019-07-17 19:05:55 -04:00
return await Promise.all(keys.map(k => db.getSortedSetRange(k, 0, -1)));
};
2019-07-17 19:05:55 -04:00
Posts.uploads.associate = async function (pid, filePaths) {
2018-04-13 16:12:11 -04:00
filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths;
2018-05-19 12:57:59 -04:00
if (!filePaths.length) {
2019-07-17 19:05:55 -04:00
return;
2018-05-19 12:57:59 -04:00
}
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);
}
});
2019-07-17 19:05:55 -04:00
const now = Date.now();
2021-02-03 23:59:08 -07:00
const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]);
2019-07-17 19:05:55 -04:00
await Promise.all([
db.setObjectField(`post:${pid}`, 'uploads', JSON.stringify(currentUploads)),
2019-07-17 19:05:55 -04:00
db.sortedSetAddBulk(bulkAdd),
Posts.uploads.saveSize(filePaths),
]);
2018-04-13 16:12:11 -04:00
};
2019-07-17 19:05:55 -04:00
Posts.uploads.dissociate = async function (pid, filePaths) {
2018-04-13 16:12:11 -04:00
filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths;
2018-05-19 12:57:59 -04:00
if (!filePaths.length) {
2019-07-17 19:05:55 -04:00
return;
2018-05-19 12:57:59 -04:00
}
let currentUploads = await Posts.uploads.list(pid);
currentUploads = currentUploads.filter(upload => !filePaths.includes(upload));
2021-02-03 23:59:08 -07:00
const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]);
const promises = [
db.setObjectField(`post:${pid}`, 'uploads', JSON.stringify(currentUploads)),
2019-07-17 19:05:55 -04:00
db.sortedSetRemoveBulk(bulkRemove),
];
await Promise.all(promises);
if (!meta.config.preserveOrphanedUploads) {
const deletePaths = (await Promise.all(
filePaths.map(async filePath => (await Posts.uploads.isOrphan(filePath) ? filePath : false))
)).filter(Boolean);
const uploaderUids = (await db.getObjectsFields(
deletePaths.map(path => `upload:${md5(path)}`, ['uid'])
)).map(o => (o ? o.uid || null : null));
await Promise.all(uploaderUids.map((uid, idx) => (
uid && isFinite(uid) ? user.deleteUpload(uid, uid, deletePaths[idx]) : null
)).filter(Boolean));
await Posts.uploads.deleteFromDisk(deletePaths);
}
2018-04-13 16:12:11 -04:00
};
2019-09-04 16:58:58 -04:00
Posts.uploads.dissociateAll = async (pid) => {
const current = await Posts.uploads.list(pid);
await Posts.uploads.dissociate(pid, current);
};
Posts.uploads.deleteFromDisk = async (filePaths) => {
if (typeof filePaths === 'string') {
filePaths = [filePaths];
} else if (!Array.isArray(filePaths)) {
throw new Error(`[[error:wrong-parameter-type, filePaths, ${typeof filePaths}, array]]`);
}
filePaths = (await _filterValidPaths(filePaths)).map(_getFullPath);
await Promise.all(filePaths.map(file.delete));
2019-09-04 16:58:58 -04:00
};
Posts.uploads.saveSize = async (filePaths) => {
filePaths = filePaths.filter((fileName) => {
const type = mime.getType(fileName);
return type && type.match(/image./);
});
2021-02-04 00:01:39 -07:00
await Promise.all(filePaths.map(async (fileName) => {
try {
const size = await image.size(_getFullPath(fileName));
2021-02-03 23:59:08 -07:00
await db.setObject(`upload:${md5(fileName)}`, {
2019-07-17 19:05:55 -04:00
width: size.width,
height: size.height,
});
} catch (err) {
2021-02-03 23:59:08 -07:00
winston.error(`[posts/uploads] Error while saving post upload sizes (${fileName}): ${err.message}`);
}
}));
};
2018-04-13 16:12:11 -04:00
};