Files
f0ckm/src/upload_handler.mjs
T
2026-09-12 01:44:03 +02:00

1112 lines
53 KiB
JavaScript

import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import cfg from "./inc/config.mjs";
import { applyWordFilter } from "./inc/wordfilter.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import https from "https";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
import { addPrivateItem } from "./inc/private_items.mjs";
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
// Adding a new archive type to config.json is sufficient; no code change needed.
const ARCHIVE_MIMES = new Set(
Object.entries(cfg.mimes)
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
.map(([mime]) => mime)
);
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
const autoTagsFromUrl = (urlString) => {
const tags = [];
try {
const { hostname } = new URL(urlString);
const host = hostname.replace(/:\d+$/, '').toLowerCase();
const parts = host.split('.');
const shortSlds = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne']);
let domain;
if (parts.length >= 3 && shortSlds.has(parts[parts.length - 2])) {
domain = parts.slice(-3).join('.');
} else {
domain = parts.slice(-2).join('.');
}
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
tags.push('youtube');
}
} catch (e) {}
return [...new Set(tags)];
};
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
// One-time migration: add original_filename column if it doesn't exist
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text`.catch(() => {});
// One-time migration: restore title column for backwards compatibility with old databases
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text`.catch(() => {});
// One-time migration: add width/height columns for image and video dimension storage
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {});
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {});
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`.catch(() => {});
// One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support
// (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more)
db`ALTER TABLE items ALTER COLUMN checksum TYPE character varying(255)`.catch(() => {});
// One-time migration: widen dest column to varchar(60) — UUID (32) + dot + extension can exceed 40 chars
db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
// One-time migration: fix NULL visibility values and ensure NOT NULL default going forward
db`UPDATE items SET visibility = 0 WHERE visibility IS NULL`.catch(() => {});
db`ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0`.catch(() => {});
export const handleUpload = async (req, res, self) => {
// Manual session lookup is required here because this handler is called from a
// bypass middleware that runs in parallel with the main session middleware.
if (req.cookies?.session) {
try {
const user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
if (user.length > 0) {
req.session = user[0];
}
} catch (err) {
}
}
// Fallback: authenticate via X-Api-Key header (upload-only; no CSRF required)
if (!req.session && req.headers['x-api-key'] && cfg.websrv.enable_user_api_keys !== false) {
const key = req.headers['x-api-key'];
try {
const rows = await db`
SELECT u.id, u.user, u.login, u.admin, u.is_moderator, u.banned,
uo.*
FROM user_api_keys k
JOIN "user" u ON u.id = k.user_id
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE k.api_key = ${key}
LIMIT 1
`;
if (rows.length > 0) {
if (rows[0].banned) {
return sendJson(res, { success: false, msg: 'Account banned' }, 403);
}
req.session = { ...rows[0], api_key_auth: true };
}
} catch (err) {
console.error('[UPLOAD] API key lookup error:', err);
}
}
if (!req.session) {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
// CSRF validation — required for browser sessions, skipped for API key auth.
if (!req.session.api_key_auth) {
const csrfToken = req.headers['x-csrf-token'];
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
}
try {
const contentType = req.headers['content-type'] || '';
let parts = {};
// Determine max file size early for collectBody
let effectiveMaxBytes = cfg.main.maxfilesize || (150 * 1024 * 1024);
if (req.session?.admin) {
effectiveMaxBytes = Math.floor(effectiveMaxBytes * (cfg.main.adminmultiplier || 10));
}
let body;
try {
body = await collectBody(req, effectiveMaxBytes);
} catch (bodyErr) {
throw bodyErr;
}
if (contentType.includes('multipart/form-data')) {
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
if (!boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid multipart boundary' }, 400);
}
const boundary = boundaryMatch[1] || boundaryMatch[2];
parts = parseMultipart(body, boundary);
} else if (contentType.includes('application/json')) {
try {
parts = JSON.parse(body.toString());
} catch (e) {
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
}
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const params = new URLSearchParams(body.toString());
for (const [key, value] of params.entries()) {
parts[key] = value;
}
} else {
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
// Validate required fields
let file = (typeof parts.file === 'object' && parts.file !== null && parts.file.data) ? parts.file : null;
let inputUrl = (typeof parts.url === 'string' && parts.url.trim()) ? parts.url.trim() : null;
if (!inputUrl && typeof parts.file === 'string' && /^https?:\/\//i.test(parts.file.trim())) {
inputUrl = parts.file.trim();
}
if (inputUrl) {
file = null;
try {
const parsed = new URL(inputUrl);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
inputUrl = parsed.toString();
}
} catch (e) {}
}
const rating = parts.rating;
const tagsRaw = parts.tags;
const comment = parts.comment ? String(parts.comment).trim() : '';
const rawTitle = parts.title ? String(parts.title).trim() : '';
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
const is_shitpost = (parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
if (!file && !inputUrl) {
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
}
if (inputUrl && cfg.websrv.web_url_upload === false) {
return sendJson(res, { success: false, msg: 'URL uploads are disabled' }, 403);
}
// Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference
let targetVisibility = 0;
if (cfg.enable_private_uploads !== false) {
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
targetVisibility = sysDefault;
} else {
const rawVisHeader = req.headers['x-upload-visibility'];
const rawVisBody = parts.visibility;
const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase();
if (visVal === 'private' || visVal === '2') {
targetVisibility = 2;
} else if (visVal === 'unlisted' || visVal === '1') {
targetVisibility = 1;
} else if (visVal === 'public' || visVal === '0') {
targetVisibility = 0;
} else {
targetVisibility = (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
}
}
}
const rawExpiry = req.headers['x-upload-expiry'] || parts.expiry || parts.expires_at;
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(rawExpiry, nowStamp);
// Always generate a unique item slug for the database
const itemSlug = lib.generateSlug(11);
const maxLen = cfg.main.comment_max_length;
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
}
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null;
if (!is_shitpost && !effectiveRating) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
}
if (is_shitpost && cfg.websrv.shitpost_require_rating === true && !effectiveRating) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required for each item' }, 400);
}
if (effectiveRating === 'nsfl' && !cfg.enable_nsfl) {
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
}
const userTags = tagsRaw ? String(tagsRaw).split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
const autoTags = (inputUrl && !file) ? autoTagsFromUrl(inputUrl) : [];
const tags = [...new Set([...userTags, ...autoTags])];
const minTags = getMinTags();
const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0;
if (!is_shitpost && minTags > 0 && userTags.length < minTags) {
return sendJson(res, { success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
}
if (is_shitpost && shitpostMinTags > 0 && userTags.length < shitpostMinTags) {
return sendJson(res, { success: false, msg: `At least ${shitpostMinTags} tag${shitpostMinTags !== 1 ? 's' : ''} required` }, 400);
}
// Validate MIME type for attached file
const allowedCats = Array.isArray(cfg.allowedMimes)
? cfg.allowedMimes.map(c => c.toLowerCase())
: null;
const allowedMimes = allowedCats
? Object.keys(cfg.mimes).filter(m =>
allowedCats.some(cat =>
cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)
)
)
: Object.keys(cfg.mimes);
if (file && !inputUrl) {
let mime = file.contentType;
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
mime = 'application/x-shockwave-flash';
}
if (!allowedMimes.includes(mime)) {
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
}
}
let manualApproval = getManualApproval();
const trustedThreshold = getTrustedUploads();
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
try {
const totalUploads = await db`
SELECT count(*) as count
FROM items
WHERE username = ${req.session.user}
AND is_deleted = false
`;
if (parseInt(totalUploads[0].count) < trustedThreshold) {
manualApproval = true;
}
} catch (err) {
manualApproval = true;
}
}
if (!manualApproval && !req.session.admin && !req.session.is_moderator) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db`
SELECT count(*) as count
FROM items
WHERE username = ${req.session.user}
AND stamp > ${twelveHoursAgo}
AND is_deleted = false
`;
const uploadLimit = cfg.main.upload_limit ?? 69;
if (parseInt(uploadCount[0].count) >= uploadLimit) {
return sendJson(res, {
success: false,
msg: `Rate limit exceeded. You can only upload ${uploadLimit} files every 12 hours.`
}, 429);
}
}
// Check for YouTube Embeds when url is provided without a file attachment
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = (!file && inputUrl) ? inputUrl.match(ytRegex) : null;
if (ytMatch && cfg.websrv.enable_youtube_upload !== false) {
const videoId = ytMatch[1];
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
const filename = `yt:${videoId}`;
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
dest: filename,
mime: 'video/youtube',
size: 0,
checksum: `yt_${videoId}_${Date.now()}`,
phash: null,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
active: !manualApproval,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
RETURNING id
`;
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
try {
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval);
} catch (err) {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
}
if (effectiveRating) {
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
await queue.genBlurredThumbnail(itemid, manualApproval).catch(() => {});
}
for (const tagName of tags) {
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
if (tagRow.length === 0) {
await db`insert into tags ${db({ tag: tagName }, 'tag')}`;
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
}
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
}
if (comment && comment.length > 0) {
try {
const filteredComment = await applyWordFilter(comment);
await db`INSERT INTO comments ${db({ item_id: itemid, user_id: req.session.id, content: filteredComment })}`;
} catch (err) {}
}
if (is_oc) {
const ocTags = ['oc', 'original content'];
for (const ocName of ocTags) {
let tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
if (tagRow.length === 0) {
await db`insert into tags ${db({ tag: ocName }, 'tag')}`;
tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
}
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
}
}
if (!manualApproval) {
f0cklib.clearCountCache();
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: 'video/youtube',
username: req.session.user,
display_name: req.session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility
})})`;
} catch (err) {
console.error('[UPLOAD] YouTube new_item notify failed:', err);
}
}
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
const successMsg = manualApproval ? 'Upload successful! Your upload is pending admin approval.' : 'Upload successful! Your upload is now live.';
return sendJson(res, {
success: true,
msg: successMsg,
itemid: itemid,
slug: itemSlug,
visibility: targetVisibility,
manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
file_url: null,
dest: filename,
mime: 'video/youtube',
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
display_name: req.session.display_name || null,
username: req.session.user
});
}
// Check repost link if uploading via URL (non-YouTube)
if (!file && inputUrl && !getBypassDuplicateCheck()) {
const repostLink = await queue.checkrepostlink(inputUrl);
if (repostLink) {
return sendJson(res, { success: false, msg: 'This URL has already been uploaded', repost: repostLink }, 409);
}
}
// Generate UUID & Base Paths
const uuid = await queue.genuuid();
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
// Ensure directories exist
await fs.mkdir(cfg.paths.tmp, { recursive: true });
await fs.mkdir(path.join(cfg.paths.pending, 'b'), { recursive: true });
await fs.mkdir(path.join(cfg.paths.pending, 't'), { recursive: true });
await fs.mkdir(path.join(cfg.paths.pending, 'ca'), { recursive: true });
if (file && file.data) {
await fs.writeFile(tmpPath, file.data);
} else if (inputUrl) {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors'];
const isInstagram = /instagram\.com/i.test(inputUrl);
let downloadedFile = null;
try {
const { stdout } = await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
inputUrl,
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
'--postprocessor-args', 'ffmpeg:-bitexact',
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath',
'--merge-output-format', 'mp4'
], { quiet: true });
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err1) {
if (isInstagram) {
return sendJson(res, { success: false, msg: 'Failed to download Instagram URL' }, 400);
}
try {
const { stdout } = await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
inputUrl,
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath'
], { quiet: true });
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err2) {
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.curl_tmp`);
let referer = inputUrl;
try {
const parsedUrl = new URL(inputUrl);
let host = parsedUrl.hostname;
if (host.includes('imgur.com')) host = 'imgur.com';
referer = `${parsedUrl.protocol}//${host}/`;
} catch (e) {}
const curlArgs = [
'-s', '-S', '-f', '-L', inputUrl, '-o', fallbackTmp,
'--max-filesize', `${effectiveMaxBytes}`,
'--connect-timeout', '30',
'--max-time', '300',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'--referer', referer,
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'-H', 'Accept-Language: en-US,en;q=0.9'
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
try {
await queue.spawn('curl', curlArgs);
downloadedFile = fallbackTmp;
} catch (curlErr) {
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
}
}
}
let exists = false;
if (downloadedFile) {
try {
await fs.access(downloadedFile);
exists = true;
} catch (e) {}
}
if (!exists) {
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
}
if (downloadedFile !== tmpPath) {
await fs.rename(downloadedFile, tmpPath);
}
}
// Verify actual MIME (second check after file-command detection)
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
if (actualMime === 'application/pdf' && !getEnablePdf()) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: 'PDF uploads are currently disabled.' }, 403);
}
if (isArchiveMime(actualMime) && cfg.websrv.enable_archive === false) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: 'Archive uploads are currently disabled.' }, 403);
}
// ── Phase A: parallel analysis of tmpPath ─────────────────────────────
// sha256sum, PHash, and dimension probing all just read the file —
// none depends on the other, so run them concurrently.
const needsPHash = !isArchiveMime(actualMime);
const needsDims = !isArchiveMime(actualMime);
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
const [
checksumResult,
phashResult,
dimResult,
audioMp4Result
] = await Promise.all([
// 1. SHA-256 checksum
queue.spawn('sha256sum', [tmpPath]).then(r => r.stdout.trim().split(' ')[0]),
// 2. Perceptual hash (skip for archives)
needsPHash
? queue.generatePHash(tmpPath).catch(e => { console.error('[UPLOAD] PHash error:', e); return null; })
: Promise.resolve(null),
// 3. Pixel dimensions (skip for archives)
needsDims
? (async () => {
try {
if (actualMime.startsWith('image/')) {
const { stdout: magickOut } = await queue.spawn('magick', [
'identify', '-format', '%wx%h\n', tmpPath + '[0]'
], { quiet: true, ignoreExitCode: true });
const line = magickOut.trim().split('\n')[0];
const match = line.match(/^(\d+)x(\d+)$/);
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
const { stdout: probeOut } = await queue.spawn('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0',
tmpPath
], { quiet: true, ignoreExitCode: true });
const dimParts = probeOut.trim().split(',');
if (dimParts.length >= 2) {
const w = parseInt(dimParts[0], 10);
const h = parseInt(dimParts[1], 10);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
}
}
} catch (dimErr) {
console.warn(`[UPLOAD] Dimension probe failed for ${actualMime} (non-fatal):`, dimErr.message);
}
return null;
})()
: Promise.resolve(null),
// 4. Audio-only MP4 reclassification probe (only when needed)
needsAudioMP4
? (async () => {
const origExt = file?.filename ? file.filename.split('.').pop().toLowerCase() : (inputUrl ? inputUrl.split('?')[0].split('.').pop().toLowerCase() : '');
if (['m4a', 'aac'].includes(origExt)) {
return 'audio/mp4'; // Extension-based reclassification
}
try {
const probeResult = await queue.spawn('ffprobe', [
'-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=codec_type',
'-of', 'csv=p=0', tmpPath
]);
if (!probeResult.stdout.trim()) return 'audio/mp4';
} catch (err) { /* ffprobe unavailable, keep original */ }
return null; // No reclassification needed
})()
: Promise.resolve(null),
]);
const checksum = checksumResult;
let phash = phashResult;
const itemWidth = dimResult?.width ?? null;
const itemHeight = dimResult?.height ?? null;
// Apply audio/MP4 reclassification if detected
if (audioMp4Result) {
actualMime = audioMp4Result;
console.log(`[UPLOAD] Reclassified to ${audioMp4Result}`);
}
// Resolve final filename now that actualMime is settled
const ext = cfg.mimes[actualMime] || 'bin';
const filename = `${uuid}.${ext}`;
const destPath = path.join(cfg.paths.pending, 'b', filename);
// ── Phase B: parallel duplicate checks ────────────────────────────────
// Both are read-only DB queries — run them together.
if (!getBypassDuplicateCheck()) {
const [repostBySum, repostByPhash] = await Promise.all([
queue.checkrepostsum(checksum),
(phash ? queue.checkrepostphash(phash) : Promise.resolve(null)),
]);
if (repostBySum) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'This file already exists', repost: repostBySum }, 409);
}
if (repostByPhash) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'This file is a visual duplicate', repost: repostByPhash }, 409);
}
}
// ─────────────────────────────────────────────────────────────────────
// When bypass is active, symlink to the existing file if one already exists on disk.
// We write the symlink straight into cfg.paths.b so we can skip the pending flow entirely.
let linkedToExisting = false;
if (getBypassDuplicateCheck()) {
console.error(`[UPLOAD] bypass: looking up existing file for checksum ${checksum}`);
const existing = await db`
SELECT dest, checksum FROM items
WHERE checksum = ${checksum} OR checksum LIKE ${checksum + '_bypass_%'}
ORDER BY id DESC
LIMIT 1
`;
console.error(`[UPLOAD] bypass: DB lookup found ${existing.length} row(s)`, existing.length ? existing[0].checksum : '');
if (existing.length > 0) {
const existingFile = existing[0].dest;
const existingAbsPath = path.join(cfg.paths.b, existingFile);
try {
// Resolve to the real file to avoid symlink chains
const realTargetAbsPath = await fs.realpath(existingAbsPath);
// Determine where the symlink will live
// If manual approval is enabled, it lives in pending/b. Otherwise directly in public/b.
const symlinkPath = manualApproval ? destPath : path.join(cfg.paths.b, filename);
const symlinkDir = path.dirname(symlinkPath);
// Calculate relative path for the symlink target
const relativeTarget = path.relative(symlinkDir, realTargetAbsPath);
await fs.symlink(relativeTarget, symlinkPath);
linkedToExisting = true;
console.error(`[UPLOAD] bypass: symlinked ${symlinkPath}${relativeTarget}`);
} catch (e) {
console.error(`[UPLOAD ERROR] bypass symlink failed:`, e);
}
} else {
console.error(`[UPLOAD] bypass: no existing file found for ${checksum}`);
}
}
if (!linkedToExisting) {
// Normal path: copy tmp to pending/b, to be moved to public later
await fs.copyFile(tmpPath, destPath);
}
await fs.unlink(tmpPath).catch(() => { });
// When bypass is active the real checksum may already exist in the DB (unique constraint).
// Suffix it so the INSERT can proceed — the file is genuinely a new item entry.
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
// Insert
const size = file?.data ? file.data.length : (await fs.stat(destPath).then(s => s.size).catch(() => 0));
let originalFilename = file ? (file.filename || null) : null;
if (!originalFilename && inputUrl) {
try {
const parsedName = path.basename(new URL(inputUrl).pathname);
if (parsedName && parsedName !== '/') originalFilename = parsedName;
} catch (e) {}
}
await db`
insert into items ${db({
src: inputUrl || '',
dest: filename,
mime: actualMime,
size: size,
checksum: insertChecksum,
phash: phash,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
active: !manualApproval,
is_oc: is_oc,
original_filename: originalFilename,
title: title,
width: itemWidth,
height: itemHeight,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
`;
const itemid = await queue.getItemID(filename);
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user);
}
// Automatically subscribe uploader to comment thread
try {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${req.session.id}, ${itemid})
ON CONFLICT DO NOTHING
`;
} catch (err) {
console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err);
}
// Thumbnail & Coverart
const isPending = linkedToExisting ? manualApproval : true;
let thumbProcessed = false;
// Custom Thumbnail for Flash
const dynThumbSize = 512;
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
if (parts.thumbnail && parts.thumbnail.data && parts.thumbnail.data.length > 0) {
try {
const thumbTmp = path.join(cfg.paths.tmp, `${itemid}_custom_thumb.tmp`);
await fs.writeFile(thumbTmp, parts.thumbnail.data);
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const thumbDest = path.join(tDir, `${itemid}.webp`);
await queue.spawn('magick', [thumbTmp, '-resize', `${dynThumbSize}x${dynThumbSize}^`, '-gravity', 'center', '-crop', `${dynThumbSize}x${dynThumbSize}+0+0`, '+repage', thumbDest]);
await fs.unlink(thumbTmp).catch(() => {});
thumbProcessed = true;
console.log(`[UPLOAD] Custom thumbnail processed for Flash item ${itemid}`);
} catch (thumbErr) {
console.error(`[UPLOAD] Custom thumbnail processing failed for item ${itemid}:`, thumbErr);
}
}
}
try {
if (!thumbProcessed) {
await queue.genThumbnail(filename, actualMime, itemid, '', isPending, dynThumbSize);
}
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
} catch (err) {
console.warn(`[UPLOAD WARNING] genThumbnail failed for item ${itemid} (falling back to placeholder):`, err.message);
// Fallback to placeholder for thumbnail ONLY if it hasn't been processed yet
if (!thumbProcessed) {
const tPath = !isPending
? path.join(cfg.paths.t, itemid + '.webp')
: path.join(cfg.paths.pending, 't', itemid + '.webp');
await queue.spawn('magick', ['-size', `${dynThumbSize}x${dynThumbSize}`, 'xc:#1a1a1a', tPath]).catch(() => {});
}
}
// Generate blurred thumbnail for all posts (SFW, NSFW, NSFL, Untagged)
await queue.genBlurredThumbnail(itemid, isPending);
// Insert optional first comment
if (comment && comment.length > 0) {
try {
const filteredComment = await applyWordFilter(comment);
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: req.session.id,
content: filteredComment
})}
`;
} catch (err) {
console.error('[UPLOAD HANDLER] Failed to insert comment:', err);
}
}
// Tags — rating tag only assigned if a rating was selected
if (effectiveRating) {
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
}
for (const tagName of tags) {
let tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
let tagId;
if (tagRow.length === 0) {
await db`
insert into tags ${db({ tag: tagName }, 'tag')}
`;
tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
}
tagId = tagRow[0].id;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
on conflict do nothing
`;
}
// Assign OC tags if the uploader ticked the OC checkbox
if (is_oc) {
const ocTags = ['oc', 'original content'];
for (const tagname of ocTags) {
const normalized = tagname.replace(/\s+/g, '-').toLowerCase();
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
}
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })}
ON CONFLICT DO NOTHING
`;
}
}
// Auto-tag SWF uploads with "Flash" and "SWF"
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
const swfTags = ['Flash', 'SWF'];
for (const tagname of swfTags) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
}
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })}
ON CONFLICT DO NOTHING
`;
}
}
// Action if auto-approved
if (!manualApproval) {
// Bust the count cache so page totals update immediately
f0cklib.clearCountCache();
if (!linkedToExisting) {
// Move logic: Handles both real files and symlinks (reposts) correctly
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
console.error(`[UPLOAD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
};
const itemDest = path.join(cfg.paths.b, filename);
const thumbDest = path.join(cfg.paths.t, `${itemid}.webp`);
const blurDest = path.join(cfg.paths.t, `${itemid}_blur.webp`);
const coverDest = path.join(cfg.paths.ca, `${itemid}.webp`);
await moveSafe(destPath, itemDest);
await moveSafe(path.join(cfg.paths.pending, 't', `${itemid}.webp`), thumbDest);
if (actualMime.startsWith('audio')) {
await moveSafe(path.join(cfg.paths.pending, 'ca', `${itemid}.webp`), coverDest);
}
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
await moveSafe(path.join(cfg.paths.pending, 't', `${itemid}_blur.webp`), blurDest);
}
}
}
// --- From here on, we process in the background to return to the user immediately ---
const backgroundProcess = async () => {
try {
// Thumbnail & Coverart is now primarily handled synchronously
// for immediate visual feedback in some cases, but we keep this as safety
// EXCEPT for custom thumbnails which are already processed.
const isPending = manualApproval;
try {
// If it's Flash, we might have already processed a custom thumbnail.
// We'll only run genThumbnail if the thumbnail doesn't exist yet.
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const thumbPath = path.join(tDir, `${itemid}.webp`);
const thumbExists = await fs.access(thumbPath).then(() => true).catch(() => false);
if (!thumbExists) {
await queue.genThumbnail(filename, actualMime, itemid, '', isPending, 512);
}
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
} catch (err) {
console.error(`[BACKGROUND ERROR] genThumbnail failed for item ${itemid}:`, err);
}
// Ensure blurred thumbnail exists
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const blurPath = path.join(tDir, `${itemid}_blur.webp`);
const blurExists = await fs.access(blurPath).then(() => true).catch(() => false);
if (!blurExists) {
await queue.genBlurredThumbnail(itemid, isPending).catch(err => console.error(`[BACKGROUND ERROR] genBlurredThumbnail failed:`, err));
}
// Note: video title metadata is surfaced to the user as a suggestion in the upload form.
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
// Discord Webhook (only for public uploads)
if (targetVisibility === 0) {
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => { });
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
}
}
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!manualApproval) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: actualMime,
username: req.session.user,
display_name: req.session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility
})})`;
} catch (err) {
console.error('[UPLOAD] new_item notify failed:', err);
}
}
// Push to Matrix Channel (only if auto-approved and public)
if (!manualApproval && targetVisibility === 0) {
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
}
}
} catch (err) {
console.error('[UPLOAD] Matrix notification error:', err);
}
}
// Staff Notifications
if (manualApproval) {
try {
const staff = await db`select id, login from "user" where admin = true or is_moderator = true`;
const notifications = staff.map(user => ({
user_id: user.id,
type: 'admin_pending',
reference_id: 0,
item_id: itemid
}));
if (notifications.length > 0) {
await db`INSERT INTO notifications ${db(notifications)} ON CONFLICT DO NOTHING`;
}
} catch (err) {
console.error('[UPLOAD HANDLER] Failed to notify staff:', err);
}
}
} catch (globalErr) {
console.error(`[CRITICAL BACKGROUND ERROR] Item ${itemid}:`, globalErr);
}
};
// Start background processing without awaiting
backgroundProcess();
const successMsg = manualApproval
? 'Upload successful! Your upload is pending admin approval.'
: 'Upload successful! Your upload is now live.';
const imagesPath = cfg.websrv.paths?.images || '/b';
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
return sendJson(res, {
success: true,
msg: successMsg,
itemid: itemid,
slug: itemSlug,
visibility: targetVisibility,
manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
// Fields for immediate client-side grid injection (avoids SSE race condition)
dest: filename,
mime: actualMime,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
display_name: req.session.display_name || null,
username: req.session.user
});
} catch (err) {
if (err.code === 'BODY_TOO_LARGE') {
const isBoosted = req.session?.admin || req.session?.is_moderator;
console.error(`[UPLOAD HANDLER ERROR] [BODY_TOO_LARGE] User: ${req.session?.user || 'unknown'}. Limit: ${lib.formatSize(cfg.main.maxfilesize * (isBoosted ? cfg.main.adminmultiplier : 1))}`);
return sendJson(res, { success: false, msg: 'File too large' }, 413);
}
console.error('[UPLOAD HANDLER ERROR]', err);
return sendJson(res, { success: false, msg: lib.logError(err, 'Upload failed') }, 500);
}
};