This commit is contained in:
2026-09-13 20:15:48 +02:00
parent ffd32a6b63
commit 399f2be456
24 changed files with 2159 additions and 90 deletions
+21
View File
@@ -231,6 +231,7 @@ export async function purgeExpiredUploads() {
if (item.dest) {
await safeDeleteMediaFile(item.dest, item.id);
}
await safeDeleteAlbumFiles(item.id);
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
if (item.mime && item.mime.startsWith('audio')) {
@@ -249,3 +250,23 @@ export async function purgeExpiredUploads() {
}
}
/**
* Safely delete all album image files associated with an item.
* @param {number} itemId
*/
export async function safeDeleteAlbumFiles(itemId) {
try {
const albumRows = await db`SELECT dest FROM album_items WHERE item_id = ${itemId}`;
for (const row of albumRows) {
if (row.dest) {
await safeDeleteMediaFile(row.dest, itemId);
const thumbName = row.dest.replace(/\.[^.]+$/, '.webp');
await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
}
}
await db`DELETE FROM album_items WHERE item_id = ${itemId}`.catch(() => {});
} catch (e) {
console.error(`[DELETE] Failed to delete album files for item #${itemId}:`, e);
}
}
+19
View File
@@ -831,5 +831,24 @@
"private": "Privat",
"change_visibility": "Sichtbarkeit ändern"
}
},
"album": {
"title": "Album",
"multiple_selected": "Mehrere Dateien ausgewählt",
"mode_album": "Album (1 Beitrag)",
"mode_batch": "Einzelne Beiträge",
"cover": "Titelbild",
"pictures": "Subf0cks",
"counter": "%s von %s",
"prev": "Vorheriger Subf0ck",
"next": "Nächster Subf0ck",
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
"move_left": "Nach links",
"move_right": "Nach rechts",
"remove_picture": "Subf0ck entfernen",
"add_more": "Weitere Subf0cks hinzufügen",
"drop_hint": "Wähle oder ziehe mehrere Dateien (Subf0cks) hierher, um ein Album zu erstellen",
"select_pictures": "Dateien für Album auswählen",
"min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich"
}
}
+19
View File
@@ -831,5 +831,24 @@
"private": "Private",
"change_visibility": "Change Visibility"
}
},
"album": {
"title": "Album",
"multiple_selected": "Multiple files selected",
"mode_album": "Album (1 post)",
"mode_batch": "Separate posts",
"cover": "Cover",
"pictures": "subf0cks",
"counter": "%s of %s",
"prev": "Previous subf0ck",
"next": "Next subf0ck",
"hotkey_tip": "Use [ and ] or hover to navigate subf0cks",
"move_left": "Move left",
"move_right": "Move right",
"remove_picture": "Remove subf0ck",
"add_more": "Add more subf0cks",
"drop_hint": "Select or drop multiple files (subf0cks) to create an Album",
"select_pictures": "Select files for album",
"min_pictures": "Add at least 2 subf0cks for an album"
}
}
+19
View File
@@ -821,5 +821,24 @@
"slot_refreshes_on": "slot vernieuwd op {date}",
"slot_refreshed": "slot vernieuwd",
"admin_desc": "Je bent admin, ga je gang."
},
"album": {
"title": "Album",
"multiple_selected": "Meerdere afbeeldingen geselecteerd",
"mode_album": "Album (1 bericht)",
"mode_batch": "Aparte berichten",
"cover": "Omslag",
"pictures": "afbeeldingen",
"counter": "%s van %s",
"prev": "Vorige afbeelding",
"next": "Volgende afbeelding",
"hotkey_tip": "Gebruik [ en ] of zweef over album om te navigeren",
"move_left": "Naar links",
"move_right": "Naar rechts",
"remove_picture": "Afbeelding verwijderen",
"add_more": "Meer afbeeldingen toevoegen",
"drop_hint": "Selecteer of sleep meerdere afbeeldingen om een album te maken",
"select_pictures": "Selecteer afbeeldingen voor album",
"min_pictures": "Voeg minimaal 2 afbeeldingen toe voor een album"
}
}
+19
View File
@@ -821,5 +821,24 @@
"slot_refreshes_on": "Platz erneuert sich am {date}",
"slot_refreshed": "Platz erneuert",
"admin_desc": "Du bist Admin, mach weiter."
},
"album": {
"title": "Album",
"multiple_selected": "Mehrere Bildnisse ausgewählt",
"mode_album": "Album (1 Einpfostung)",
"mode_batch": "Vereinzelte Einpfostungen",
"cover": "Deckblatt",
"pictures": "Bildnisse",
"counter": "%s von %s",
"prev": "Vorheriges Bildnis",
"next": "Nächstes Bildnis",
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
"move_left": "Nach links",
"move_right": "Nach rechts",
"remove_picture": "Bildnis entfernen",
"add_more": "Weitere Bildnisse hinzufügen",
"drop_hint": "Wähle oder droppe mehrere Bilder für 1 Album",
"select_pictures": "Bildnisse fürs Album auswählen",
"min_pictures": "Mindestens 2 Bildnisse fürs Album nötig"
}
}
+20 -3
View File
@@ -53,15 +53,32 @@ export const parseMultipart = (buffer, boundary) => {
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
let name = nameMatch[1];
if (name.endsWith('[]')) {
name = name.slice(0, -2);
}
if (extractedFilename !== null) {
parts[name] = {
const fileObj = {
filename: extractedFilename,
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
if (!parts[name]) {
parts[name] = fileObj;
} else if (Array.isArray(parts[name])) {
parts[name].push(fileObj);
} else {
parts[name] = [parts[name], fileObj];
}
} else {
parts[name] = body.toString().trim();
const textVal = body.toString().trim();
if (!parts[name]) {
parts[name] = textVal;
} else if (Array.isArray(parts[name])) {
parts[name].push(textVal);
} else {
parts[name] = [parts[name], textVal];
}
}
}
}
+16 -5
View File
@@ -503,14 +503,25 @@ export default new class queue {
} else {
// Try extracting embedded cover art (video stream in audio file)
try {
await this.spawn('ffmpeg', ['-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
const size = (await fs.promises.stat(tmpJpg)).size;
if (size > 0) {
await this.spawn('magick', [tmpJpg, tmpFile]);
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
const caWebp = path.join(cDir, itemid + '.webp');
await this.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'webp', '-frames:v', '1', caWebp]);
const stat = await fs.promises.stat(caWebp).catch(() => null);
if (stat && stat.size > 0) {
await this.spawn('magick', [caWebp + '[0]', tmpFile]);
coverExtracted = true;
}
} catch (err) { }
if (!coverExtracted) {
try {
await this.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
const size = (await fs.promises.stat(tmpJpg).catch(() => ({ size: 0 }))).size;
if (size > 0) {
await this.spawn('magick', [tmpJpg, tmpFile]);
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
coverExtracted = true;
}
} catch (err) { }
}
}
// If no new cover art extracted, check if cover art was already saved previously in cDir
if (!coverExtracted) {
+138 -6
View File
@@ -119,7 +119,9 @@ const resolveNumericItemId = async (itemIdOrSlug) => {
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
try {
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
return rows[0]?.id || null;
if (rows[0]?.id) return rows[0].id;
const subRows = await db`SELECT item_id FROM album_items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
return subRows[0]?.item_id || null;
} catch (e) {
return null;
}
@@ -770,6 +772,8 @@ const f0cklib = {
items.is_oc,
items.xd_score,
items.has_coverart,
items.is_album,
items.album_count,
${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``}
${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`}
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag,
@@ -915,7 +919,19 @@ const f0cklib = {
}
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
let itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
let requestedSubf0ckSlug = null;
if (!isNumeric) {
const itemRow = await db`SELECT id FROM items WHERE slug = ${String(rawIdOrSlug)} LIMIT 1`;
if (!itemRow.length) {
const subRow = await db`SELECT item_id, slug FROM album_items WHERE slug = ${String(rawIdOrSlug)} LIMIT 1`;
if (subRow.length) {
requestedSubf0ckSlug = subRow[0].slug;
itemLookup = db`items.id = ${subRow[0].item_id}`;
}
}
}
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = exclude || [];
@@ -1254,14 +1270,30 @@ const f0cklib = {
}
// Efficient coverart fallback
// Efficient coverart fallback with on-demand extraction
let hasCoverart = actitem.has_coverart;
if (!hasCoverart && actitem.mime?.startsWith('audio/')) {
const caPath = path.join(cfg.paths.ca, `${actitem.id}.webp`);
try {
if (fs.existsSync(caPath)) {
if (fs.existsSync(caPath) && fs.statSync(caPath).size > 0) {
hasCoverart = true;
db`UPDATE items SET has_coverart = TRUE WHERE id = ${actitem.id}`.catch(() => {});
} else {
// Attempt extraction directly from audio file if embedded
const sourcePath = path.join(cfg.paths.b, actitem.dest);
if (fs.existsSync(sourcePath)) {
await queue.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'webp', '-frames:v', '1', caPath], { quiet: true }).catch(() => {});
if (fs.existsSync(caPath) && fs.statSync(caPath).size > 0) {
hasCoverart = true;
db`UPDATE items SET has_coverart = TRUE WHERE id = ${actitem.id}`.catch(() => {});
const tPath = path.join(cfg.paths.t, `${actitem.id}.webp`);
if (!fs.existsSync(tPath) || fs.statSync(tPath).size === 0) {
await queue.spawn('magick', [caPath + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', tPath], { quiet: true }).catch(() => {});
}
} else {
try { fs.unlinkSync(caPath); } catch (_) {}
}
}
}
} catch (_) {}
}
@@ -1269,6 +1301,102 @@ const f0cklib = {
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
: `/s/img/music.webp`;
let album = [];
if (actitem.is_album) {
try {
const albumRows = await db`
SELECT id, dest, mime, size, width, height, order_index, slug, checksum
FROM album_items
WHERE item_id = ${itemid}
ORDER BY order_index ASC
`;
if (albumRows.length > 0) {
album = await Promise.all(albumRows.map(async (r, idx) => {
const order = r.order_index !== undefined && r.order_index !== null ? r.order_index : idx;
const subSlug = r.slug || r.id;
const subBase = r.dest.replace(/\.[^.]+$/, '');
const isAudio = (r.mime || '').startsWith('audio/');
let subCover = null;
let subThumb = `${cfg.websrv.paths.thumbnails}/${subBase}.webp`;
if (isAudio) {
const caFile = path.join(cfg.paths.ca, `${subBase}.webp`);
const tFile = path.join(cfg.paths.t, `${subBase}.webp`);
let caExists = false;
try {
caExists = fs.existsSync(caFile) && fs.statSync(caFile).size > 0;
} catch (_) {}
if (!caExists && order === 0 && hasCoverart) {
const parentCaFile = path.join(cfg.paths.ca, `${actitem.id}.webp`);
try {
if (fs.existsSync(parentCaFile) && fs.statSync(parentCaFile).size > 0) {
subCover = `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`;
subThumb = `${cfg.websrv.paths.thumbnails}/${actitem.id}.webp`;
caExists = true;
}
} catch (_) {}
}
if (!caExists) {
const audioSource = path.join(cfg.paths.b, r.dest);
if (fs.existsSync(audioSource)) {
try {
await queue.spawn('ffmpeg', ['-y', '-i', audioSource, '-an', '-vcodec', 'webp', '-frames:v', '1', caFile], { quiet: true });
if (fs.existsSync(caFile) && fs.statSync(caFile).size > 0) {
caExists = true;
await queue.spawn('magick', [caFile + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', tFile], { quiet: true });
} else {
try { fs.unlinkSync(caFile); } catch (_) {}
}
} catch (_) {}
}
}
if (caExists) {
if (!subCover) subCover = `${cfg.websrv.paths.coverarts}/${subBase}.webp`;
try {
if (!fs.existsSync(tFile) || fs.statSync(tFile).size === 0) {
subThumb = subCover;
}
} catch (_) {
subThumb = subCover;
}
} else {
subCover = '/s/img/audio.webp';
subThumb = '/s/img/audio.webp';
}
}
return {
id: r.id,
slug: r.slug,
subf0ck_id: subSlug,
dest: `${cfg.websrv.paths.images}/${r.dest}`,
src: `${cfg.websrv.paths.images}/${r.dest}`,
filename: r.dest,
mime: r.mime,
size: lib.formatSize(r.size),
width: r.width,
height: r.height,
checksum: r.checksum,
order_index: order,
display_index: order + 1,
is_first: order === 0,
is_video: (r.mime || '').startsWith('video/'),
is_audio: isAudio,
is_image: (r.mime || '').startsWith('image/'),
has_coverart: isAudio ? (subCover && subCover !== '/s/img/audio.webp') : false,
coverart: isAudio ? subCover : null,
thumb: subThumb
};
}));
}
} catch (err) {
console.error('[GETF0CK] Failed to fetch album items:', err.message);
}
}
const duration = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Fetch complete in ${duration}ms`);
@@ -1410,8 +1538,12 @@ const f0cklib = {
height: actitem.height || null,
original_filename: actitem.original_filename || null,
expires_at: actitem.expires_at || null,
expires_in: lib.expiresIn(actitem.expires_at)
expires_in: lib.expiresIn(actitem.expires_at),
is_album: !!(actitem.is_album && album.length > 1),
album_count: album.length || actitem.album_count || 0,
album: album,
album_json: JSON.stringify(album),
requested_subf0ck_slug: requestedSubf0ckSlug || null
},
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: {
+15
View File
@@ -144,6 +144,21 @@ export const ensureAllItemsHaveSlugs = async () => {
}
};
export const ensureAllAlbumItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM album_items WHERE slug IS NULL OR slug = ''`;
if (!rows || rows.length === 0) return;
console.log(`[ALBUM_SLUG_BACKFILL] Found ${rows.length} album item(s) missing slugs. Backfilling...`);
for (const row of rows) {
const newSlug = lib.generateSlug(11);
await db`UPDATE album_items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
}
console.log(`[ALBUM_SLUG_BACKFILL] Successfully backfilled ${rows.length} album item slug(s).`);
} catch (err) {
console.error('[ALBUM_SLUG_BACKFILL] Error during album item slug backfill:', err.message);
}
};
export const getEnableCleanup = () => {
+23 -1
View File
@@ -20,7 +20,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
@@ -524,6 +524,27 @@ process.on('uncaughtException', err => {
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip character varying(128) DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS is_album boolean DEFAULT false`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0`);
await runMigration(db`
CREATE TABLE IF NOT EXISTS album_items (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
dest CHARACTER VARYING(60) NOT NULL,
mime CHARACTER VARYING(100) NOT NULL,
size INTEGER NOT NULL,
checksum CHARACTER VARYING(255) NOT NULL,
phash TEXT,
width INTEGER,
height INTEGER,
order_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_item_id ON album_items(item_id, order_index ASC)`);
await runMigration(db`ALTER TABLE album_items ADD COLUMN IF NOT EXISTS slug character varying(60) DEFAULT NULL`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_slug ON album_items(slug)`);
// Initial halls cache (only if halls are enabled)
if (cfg.websrv.halls_enabled !== false) {
@@ -1729,6 +1750,7 @@ process.on('uncaughtException', err => {
// Ensure all items in database have a unique slug backfilled
ensureAllItemsHaveSlugs();
ensureAllAlbumItemsHaveSlugs();
const globals = {
lul: cfg.websrv.lul,
+147 -4
View File
@@ -175,7 +175,13 @@ export const handleUpload = async (req, res, self) => {
}
// Validate required fields
let file = (typeof parts.file === 'object' && parts.file !== null && parts.file.data) ? parts.file : null;
let rawFiles = [];
if (Array.isArray(parts.files)) rawFiles = parts.files;
else if (Array.isArray(parts.file)) rawFiles = parts.file;
else if (parts.files && typeof parts.files === 'object' && parts.files.data) rawFiles = [parts.files];
else if (parts.file && typeof parts.file === 'object' && parts.file.data) rawFiles = [parts.file];
let file = rawFiles[0] || 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())) {
@@ -184,6 +190,7 @@ export const handleUpload = async (req, res, self) => {
if (inputUrl) {
file = null;
rawFiles = [];
try {
const parsed = new URL(inputUrl);
if (parsed.searchParams.has('igsh')) {
@@ -200,7 +207,14 @@ export const handleUpload = async (req, res, self) => {
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;
const isAlbumRequested = (parts.is_album === true || parts.is_album === 'true' || parts.is_album === '1') || (rawFiles.length > 1 && !parts.is_shitpost);
const is_album = isAlbumRequested && rawFiles.length > 1;
const is_shitpost = !is_album && ((parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true);
const maxAlbumItems = cfg.websrv?.max_album_items || cfg.websrv?.max_album_images || 100;
if (is_album && rawFiles.length > maxAlbumItems) {
return sendJson(res, { success: false, msg: `Album exceeds maximum limit of ${maxAlbumItems} items` }, 400);
}
if (!file && !inputUrl) {
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
@@ -302,6 +316,15 @@ export const handleUpload = async (req, res, self) => {
}
}
if (is_album) {
for (let i = 0; i < rawFiles.length; i++) {
const f = rawFiles[i];
if (!f || !f.data || f.data.length === 0) {
return sendJson(res, { success: false, msg: `Album item #${i + 1} is empty` }, 400);
}
}
}
let manualApproval = getManualApproval();
const trustedThreshold = getTrustedUploads();
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
@@ -776,12 +799,130 @@ export const handleUpload = async (req, res, self) => {
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt,
uploader_ip: auditIp
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip')}
uploader_ip: auditIp,
is_album: !!is_album,
album_count: is_album ? rawFiles.length : 0
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip', 'is_album', 'album_count')}
`;
const itemid = await queue.getItemID(filename);
if (is_album) {
// 1. Insert primary/cover image (file 0) as order_index 0
const coverSubSlug = lib.generateSlug(11);
await db`
INSERT INTO album_items ${db({
item_id: itemid,
dest: filename,
mime: actualMime,
size: size,
checksum: insertChecksum,
phash: phash,
width: itemWidth,
height: itemHeight,
order_index: 0,
slug: coverSubSlug
}, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
`;
// 2. Process and insert remaining album images (1 .. rawFiles.length - 1)
for (let i = 1; i < rawFiles.length; i++) {
const subFile = rawFiles[i];
if (!subFile || !subFile.data) continue;
const subUuid = await queue.genuuid();
const subTmpPath = path.join(cfg.paths.tmp, `${subUuid}.tmp`);
await fs.writeFile(subTmpPath, subFile.data);
let subMime = (await queue.spawn('file', ['--mime-type', '-b', subTmpPath])).stdout.trim();
const allowedMimesList = Object.keys(cfg.mimes || {});
if (!allowedMimesList.includes(subMime) && subMime !== 'application/x-shockwave-flash' && subMime !== 'application/vnd.adobe.flash.movie') {
const extFromMime = cfg.mimes?.[subFile.contentType] ? subFile.contentType : null;
if (extFromMime) subMime = extFromMime;
else {
await fs.unlink(subTmpPath).catch(() => {});
continue;
}
}
const [subChecksum, subPhash, subDims] = await Promise.all([
queue.spawn('sha256sum', [subTmpPath]).then(r => r.stdout.trim().split(' ')[0]),
queue.generatePHash(subTmpPath).catch(() => null),
(async () => {
try {
if (subMime.startsWith('image/')) {
const { stdout: magickOut } = await queue.spawn('magick', [
'identify', '-format', '%wx%h\n', subTmpPath + '[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 (subMime.startsWith('video/')) {
const { stdout: probeOut } = await queue.spawn('ffprobe', [
'-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', subTmpPath
], { quiet: true, ignoreExitCode: true });
const line = probeOut.trim().split('\n')[0];
const match = line.match(/^(\d+)x(\d+)$/);
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
}
} catch (e) {}
return null;
})()
]);
const subExt = cfg.mimes[subMime] || 'webp';
const subFilename = `${subUuid}.${subExt}`;
const subDestPath = manualApproval ? path.join(cfg.paths.pending, 'b', subFilename) : path.join(cfg.paths.b, subFilename);
await fs.copyFile(subTmpPath, subDestPath);
await fs.unlink(subTmpPath).catch(() => {});
const subWidth = subDims?.width ?? null;
const subHeight = subDims?.height ?? null;
const subSize = subFile.data.length;
const subItemSlug = lib.generateSlug(11);
await db`
INSERT INTO album_items ${db({
item_id: itemid,
dest: subFilename,
mime: subMime,
size: subSize,
checksum: subChecksum,
phash: subPhash,
width: subWidth,
height: subHeight,
order_index: i,
slug: subItemSlug
}, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
`;
// Generate thumbnail for album item filmstrip
try {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const thumbDest = path.join(tDir, `${subUuid}.webp`);
if (subMime.startsWith('image/')) {
await queue.spawn('magick', [subDestPath + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
} else if (subMime.startsWith('video/')) {
await queue.spawn('ffmpegthumbnailer', ['-i', subDestPath, '-s', '256', '-o', thumbDest]).catch(async () => {
await queue.spawn('ffmpeg', ['-y', '-ss', '00:00:01', '-i', subDestPath, '-vframes', '1', '-vf', 'scale=256:256:force_original_aspect_ratio=increase,crop=256:256', thumbDest]);
});
} else if (subMime.startsWith('audio/')) {
try {
const cDir = manualApproval ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
const caDest = path.join(cDir, `${subUuid}.webp`);
await queue.spawn('ffmpeg', ['-y', '-i', subDestPath, '-an', '-vcodec', 'webp', '-frames:v', '1', caDest], { quiet: true });
const caStat = await fs.stat(caDest).catch(() => null);
if (caStat && caStat.size > 0) {
await queue.spawn('magick', [caDest + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
}
} catch (_) {}
}
} catch (tErr) {
console.warn(`[UPLOAD] Failed to generate thumbnail for album item ${subFilename}:`, tErr.message);
}
}
}
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'upload',
@@ -1109,6 +1250,8 @@ export const handleUpload = async (req, res, self) => {
mime: actualMime,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
is_album: !!is_album,
album_count: is_album ? rawFiles.length : 0,
display_name: req.session.display_name || null,
username: req.session.user
});