gfsdgsd
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user