updating from dev
This commit is contained in:
@@ -85,9 +85,9 @@ export default new class {
|
||||
};
|
||||
genLink(env) {
|
||||
const link = [];
|
||||
if (env.tag) link.push("tag", env.tag);
|
||||
if (env.hall) link.push("h", env.hall);
|
||||
if (env.user) link.push("user", env.user, env.type ?? 'uploads');
|
||||
if (env.tag) link.push("tag", encodeURIComponent(env.tag));
|
||||
if (env.hall) link.push("h", encodeURIComponent(env.hall));
|
||||
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
|
||||
|
||||
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||
if (!tmp.endsWith('/'))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"my_halls": "Meine Hallen",
|
||||
"favs": "Favoriten",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"mod": "Mod",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
"notifications": "Nuttis",
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Übersicht",
|
||||
"prev": "zurück",
|
||||
"next": "weiter",
|
||||
"random_nav": "Zufall"
|
||||
"random_nav": "Zufall",
|
||||
"abyss": "Abgrund"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Hochladen",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Löschen",
|
||||
"preferences": "Einstellungen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"appearance_section": "Erscheinungsbild",
|
||||
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
@@ -141,6 +143,11 @@
|
||||
"embed_yt_hint": "YouTube-Videos durch eingebettete Videoplayer ersetzen",
|
||||
"hide_koepfe": "Köpfe ausblenden",
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||
"comment_display_linear": "Linear / Flach (4chan-Stil)",
|
||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||
"forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
|
||||
"language": "Sprache",
|
||||
"language_hint": "Seitensprache ändern. Lädt die Seite zur Übernahme neu.",
|
||||
"language_default": "Standard (Seite)",
|
||||
@@ -158,10 +165,17 @@
|
||||
"font_default": "Standard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standard-Flash-Lautstärke",
|
||||
|
||||
"flash_bg": "Flash im Hintergrund abspielen",
|
||||
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Tab verlassen wird",
|
||||
"save_flash": "Flash-Einstellungen speichern",
|
||||
|
||||
"notifications_section": "Benachrichtigungseinstellungen",
|
||||
"receive_system_notifications": "Systembenachrichtigungen erhalten",
|
||||
"receive_system_notifications_hint": "Benachrichtigungen über Erfolg/Fehler bei Hintergrund-Uploads umschalten. Kritische Moderationsaktionen werden immer angezeigt.",
|
||||
"receive_user_notifications": "Benutzerbenachrichtigungen erhalten",
|
||||
"receive_user_notifications_hint": "Benachrichtigungen für Kommentarantworten, Erwähnungen und Thread-Aktivitäten umschalten.",
|
||||
"do_not_disturb": "Bitte nicht stören",
|
||||
"do_not_disturb_hint": "Unterdrückt einfach alle Benachrichtigungen und haptisches Feedback.",
|
||||
"content_filters": "Inhaltsfilter",
|
||||
"min_xd_score": "Mindest-xD-Score",
|
||||
"min_xd_score_hint": "Nur Beiträge mit mindestens diesem xD-Score anzeigen. Auf 0 setzen zum Deaktivieren.",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"nav": {
|
||||
"upload": "upload",
|
||||
"meme": "meme",
|
||||
"upload": "Upload",
|
||||
"meme": "Meme",
|
||||
"halls": "Halls",
|
||||
"tags": "Tags",
|
||||
"search": "Search",
|
||||
"random": "Random",
|
||||
"profile": "profile",
|
||||
"profile": "Profile",
|
||||
"my_halls": "My Halls",
|
||||
"favs": "favs",
|
||||
"favs": "Favs",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"settings": "settings",
|
||||
"logout": "logout",
|
||||
"mod": "Mod",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"notifications": "Notifications",
|
||||
"mark_all_read": "Mark all read",
|
||||
"no_notifications": "No new notifications",
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Overview",
|
||||
"prev": "prev",
|
||||
"next": "next",
|
||||
"random_nav": "random"
|
||||
"random_nav": "random",
|
||||
"abyss": "Abyss"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload Content",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Clear",
|
||||
"preferences": "Preferences",
|
||||
"ui_section": "User Interface",
|
||||
"appearance_section": "Appearance",
|
||||
"show_motd": "Show Message of the Day (MOTD)",
|
||||
"modern_layout": "Modern layout",
|
||||
"modern_layout_hint": "3 Column Layout",
|
||||
@@ -141,6 +143,11 @@
|
||||
"embed_yt_hint": "Replace YouTube links with inline video players",
|
||||
"hide_koepfe": "Hide Köpfe",
|
||||
"hide_koepfe_hint": "Disable the Köpfe",
|
||||
"comment_display_mode": "Comment Display Mode",
|
||||
"comment_display_tree": "Reply Tree (Default)",
|
||||
"comment_display_linear": "Linear / Flat (4chan style)",
|
||||
"comment_display_mode_hint": "Choose how you want comments to be displayed.",
|
||||
"forced_mode_notice": "This setting is managed by an administrator.",
|
||||
"language": "Language",
|
||||
"language_hint": "Change the site language. Reloads the page to apply.",
|
||||
"language_default": "Default (site)",
|
||||
@@ -158,10 +165,17 @@
|
||||
"font_default": "Default",
|
||||
"theme": "Theme",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Default Flash Volume",
|
||||
|
||||
"flash_bg": "Keep Flash Playing in Background",
|
||||
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
|
||||
"save_flash": "Save Flash Settings",
|
||||
|
||||
"notifications_section": "Notification Preferences",
|
||||
"receive_system_notifications": "Receive System Notifications",
|
||||
"receive_system_notifications_hint": "Toggle background upload success/error notifications. Critical mod actions will always be shown.",
|
||||
"receive_user_notifications": "Receive User Notifications",
|
||||
"receive_user_notifications_hint": "Toggle notifications for comment replies, mentions, and thread activity.",
|
||||
"do_not_disturb": "Do Not Disturb",
|
||||
"do_not_disturb_hint": "Simply suppresses all notifications and haptic feedback.",
|
||||
"content_filters": "Content Filters",
|
||||
"min_xd_score": "Minimum xD Score",
|
||||
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
|
||||
@@ -418,14 +432,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"page_title": "MESSAGES",
|
||||
"manage_keys": "🔑 Manage Keys",
|
||||
"manage_keys": "Manage Keys",
|
||||
"loading": "Loading conversations…",
|
||||
"decrypting": "Decrypting messages…",
|
||||
"input_placeholder": "Write a message…",
|
||||
"send": "Send"
|
||||
},
|
||||
"profile": {
|
||||
"message_btn": "✉ Message",
|
||||
"message_btn": "Message",
|
||||
"legacy_record": "Legacy Record – First Upload:",
|
||||
"joined": "Joined:",
|
||||
"age_days": "{n} days",
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Overzicht",
|
||||
"prev": "vorige",
|
||||
"next": "volgende",
|
||||
"random_nav": "willekeurig"
|
||||
"random_nav": "willekeurig",
|
||||
"abyss": "Afgrond"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Inhoud Uploaden",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Wissen",
|
||||
"preferences": "Voorkeuren",
|
||||
"ui_section": "Gebruikersinterface",
|
||||
"appearance_section": "Uiterlijk",
|
||||
"show_motd": "Toon Bericht van de Dag (MOTD)",
|
||||
"modern_layout": "Moderne layout",
|
||||
"modern_layout_hint": "Indeling met 3 kolommen",
|
||||
@@ -141,6 +143,11 @@
|
||||
"embed_yt_hint": "YouTube-links vervangen door inline videospelers",
|
||||
"hide_koepfe": "Köpfe verbergen",
|
||||
"hide_koepfe_hint": "De Köpfe uitschakelen",
|
||||
"comment_display_mode": "Reactie-weergavemodus",
|
||||
"comment_display_tree": "Antwoordboom (Standaard)",
|
||||
"comment_display_linear": "Lineair / Vlak (4chan-stijl)",
|
||||
"comment_display_mode_hint": "Kies hoe je reacties wilt laten weergeven.",
|
||||
"forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
|
||||
"language": "Taal",
|
||||
"language_hint": "Wijzig de sitetaal. Pagina wordt herladen om toe te passen.",
|
||||
"language_default": "Standaard (site)",
|
||||
@@ -158,10 +165,17 @@
|
||||
"font_default": "Standaard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standaard Flash Volume",
|
||||
|
||||
"flash_bg": "Flash in de achtergrond blijven afspelen",
|
||||
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
|
||||
"save_flash": "Flash Instellingen Opslaan",
|
||||
|
||||
"notifications_section": "Meldingvoorkeuren",
|
||||
"receive_system_notifications": "Systeemmeldingen ontvangen",
|
||||
"receive_system_notifications_hint": "Schakel meldingen over succes/fout bij achtergronduploads in/uit. Kritieke moderatie-acties worden altijd getoond.",
|
||||
"receive_user_notifications": "Gebruikersmeldingen ontvangen",
|
||||
"receive_user_notifications_hint": "Schakel meldingen voor reacties, vermeldingen en thread-activiteit in/uit.",
|
||||
"do_not_disturb": "Niet storen",
|
||||
"do_not_disturb_hint": "Onderdrukt gewoon alle meldingen en haptische feedback.",
|
||||
"content_filters": "Inhoudsfilters",
|
||||
"min_xd_score": "Minimale xD-score",
|
||||
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"profile": "Profil",
|
||||
"my_halls": "Meine Hallen",
|
||||
"favs": "Favoriten",
|
||||
"admin": "Administrator",
|
||||
"mod": "Moderator",
|
||||
"admin": "Administration",
|
||||
"mod": "Moderation",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmeldung",
|
||||
"notifications": "Hinweise",
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Überblick",
|
||||
"prev": "zurück",
|
||||
"next": "weiter",
|
||||
"random_nav": "Zufall"
|
||||
"random_nav": "Zufall",
|
||||
"abyss": "Abgrund"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Aufladieren",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Leeren",
|
||||
"preferences": "Präferenzen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"appearance_section": "Erscheinungsbild",
|
||||
"show_motd": "Nachricht des Tages (NdT) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
@@ -137,10 +139,15 @@
|
||||
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
|
||||
"render_emojis": "Emojis in Zitatantworten darstellen",
|
||||
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
|
||||
"embed_yt": "DuRöhre-Verknüpfungen in Kommentaren einbetten",
|
||||
"embed_yt_hint": "Ersetzen Sie DuRöhre-Verknüpfungen durch integrierte Videospieler",
|
||||
"embed_yt": "Röhrenelfen in Kommentaren einbetten",
|
||||
"embed_yt_hint": "Röhrenelfen durch integrierten Röhrenspieler",
|
||||
"hide_koepfe": "Köpfe verbergen",
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||
"comment_display_linear": "Linear / Flach (Vierkanal-Stil)",
|
||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||
"forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
|
||||
"language": "Sprache",
|
||||
"language_hint": "Ändern Sie die Seitensprache. Lädt die Seite neu, um die Änderungen anzuwenden.",
|
||||
"language_default": "Standard (Seite)",
|
||||
@@ -150,24 +157,30 @@
|
||||
"language_zange": "Zangendeutsch",
|
||||
"scroll_nav": "Mausrad-Navigation auf Elementen",
|
||||
"scroll_nav_hint": "Navigieren Sie zum nächsten/vorherigen Element durch Scrollen auf dem Medienbereich",
|
||||
"username_color": "Benutzerdefinierte Benutzername-Farbe",
|
||||
"username_color": "Namensfarbe",
|
||||
"username_color_hint": "Wählen Sie eine Farbe oder geben Sie einen Hex-Code für Ihren Benutzernamen auf Elementen und Kommentaren ein.",
|
||||
"save_color": "Farbe speichern",
|
||||
"reset": "Zurücksetzen",
|
||||
"website_font": "Schriftart der Weltnetzpräsenz",
|
||||
"font_default": "Standard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standard-Flash-Lautstärke",
|
||||
"flash_bg": "Flash im Hintergrund weiterlaufen lassen",
|
||||
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Reiter verlassen wird",
|
||||
"save_flash": "Flash-Einstellungen speichern",
|
||||
"flash_section": "Blitz",
|
||||
"flash_bg": "Blitz im Hintergrund weiterlaufen lassen",
|
||||
"flash_bg_hint": "Verhindert, dass ein Blitz pausiert, wenn der Reiter verlassen wird",
|
||||
|
||||
"notifications_section": "Hinweis-Präferenzen",
|
||||
"receive_system_notifications": "Systemhinweise erhalten",
|
||||
"receive_system_notifications_hint": "Hinweise über Erfolg/Abbruch bei Hintergrund-Aufladierungen umschalten. Kritische Moderations-Eingriffe werden stets kundgetan.",
|
||||
"receive_user_notifications": "Benutzerhinweise erhalten",
|
||||
"receive_user_notifications_hint": "Hinweise für Kommentarantworten, Erwähnungen und Faden-Aktivitäten umschalten.",
|
||||
"do_not_disturb": "Ich wünsche keine Störung",
|
||||
"do_not_disturb_hint": "Unterdrückt jegliche Hinweise und haptisches Beben.",
|
||||
"content_filters": "Inhaltsfilter",
|
||||
"min_xd_score": "Minimaler xD-Punktestand",
|
||||
"min_xd_score_hint": "Zeige nur Pfosten mit mindestens diesem xD-Punktestand an. Auf 0 setzen zum Deaktivieren.",
|
||||
"save": "Speichern",
|
||||
"account": "Konto",
|
||||
"user_id": "Benutzeridentifikation",
|
||||
"user_id": "ID",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"display_name_placeholder": "Anzeigename",
|
||||
@@ -651,4 +664,4 @@
|
||||
"replying_to": "Antwort an {user}",
|
||||
"reply": "Antworten"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import fs from "fs";
|
||||
import db from "./sql.mjs";
|
||||
import cfg from "./config.mjs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
export default new class queue {
|
||||
|
||||
@@ -62,7 +63,12 @@ export default new class queue {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use queue.spawn() instead — exec() invokes a shell and is vulnerable to injection if passed user input. */
|
||||
exec(cmd, options = {}) {
|
||||
if (!this._execDeprecated) {
|
||||
console.warn('[DEPRECATED] queue.exec() is deprecated — use queue.spawn() to avoid shell injection risks');
|
||||
this._execDeprecated = true;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
_exec(cmd, { maxBuffer: 5e3 * 1024, ...options }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
@@ -84,6 +90,11 @@ export default new class queue {
|
||||
// 3. Generate dHash for each.
|
||||
// 4. Return combined hash "hash1_hash2_hash3".
|
||||
|
||||
// Skip ffprobe for PDFs (which would fail with "Invalid data")
|
||||
if (source.toLowerCase().endsWith('.pdf')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (isNaN(duration) || duration <= 0) return null;
|
||||
@@ -239,10 +250,25 @@ export default new class queue {
|
||||
const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
|
||||
const tmpFile = path.join(cfg.paths.tmp, itemid + '.png');
|
||||
const tmpJpg = path.join(cfg.paths.tmp, itemid + '.jpg');
|
||||
const tmpFile = path.join(os.tmpdir(), itemid + '.png');
|
||||
const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg');
|
||||
|
||||
if (mime.startsWith('video/') || mime == 'image/gif') {
|
||||
if (mime === 'video/youtube') {
|
||||
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
|
||||
if (videoId) {
|
||||
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
try {
|
||||
const curlArgs = ['-s', '-L', thumbUrl, '-o', tmpFile];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
curlArgs.push('--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`);
|
||||
}
|
||||
await this.spawn('curl', curlArgs);
|
||||
} catch (err) {
|
||||
console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (mime.startsWith('video/') || mime == 'image/gif') {
|
||||
const seeks = ['20%', '40%', '60%', '80%'];
|
||||
for (const seek of seeks) {
|
||||
await this.spawn('ffmpegthumbnailer', ['-i', path.join(bDir, filename), '-s', '1024', '-t', seek, '-o', tmpFile]);
|
||||
@@ -257,11 +283,14 @@ export default new class queue {
|
||||
else if (mime.startsWith('audio/')) {
|
||||
let coverExtracted = false;
|
||||
if (link.match(/soundcloud/)) {
|
||||
let cover = (await this.spawn('yt-dlp', ['-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim();
|
||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : [];
|
||||
let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
if (!cover.match(/default_avatar/)) {
|
||||
cover = cover.replace(/-(large|original)\./, '-t500x500.');
|
||||
try {
|
||||
await this.spawn('wget', [cover, '-O', tmpJpg]);
|
||||
const curlArgs = ['-s', '-L', cover, '-o', tmpJpg];
|
||||
if (proxyArgs.length > 0) curlArgs.push(...proxyArgs);
|
||||
await this.spawn('curl', curlArgs);
|
||||
const size = (await fs.promises.stat(tmpJpg)).size;
|
||||
if (size >= 0) {
|
||||
await this.spawn('magick', [tmpJpg, tmpFile]);
|
||||
@@ -306,7 +335,6 @@ export default new class queue {
|
||||
}
|
||||
else if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
|
||||
let customThumb = cfg.websrv.swf_thumb;
|
||||
// Resolve web paths (/s/...) to the filesystem (public/s/...)
|
||||
if (customThumb && customThumb.startsWith('/')) {
|
||||
customThumb = path.join(path.resolve(), 'public', customThumb);
|
||||
}
|
||||
@@ -331,6 +359,32 @@ export default new class queue {
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
else if (mime === 'application/pdf') {
|
||||
try {
|
||||
await this.spawn('gs', [
|
||||
'-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=png16m',
|
||||
'-r150',
|
||||
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
|
||||
'-dLastPage=1',
|
||||
'-sOutputFile=' + tmpFile,
|
||||
path.join(bDir, filename)
|
||||
]);
|
||||
} catch (err) {
|
||||
console.warn(`[QUEUE] PDF extraction failed for ${itemid}, using fallback icon.`);
|
||||
const pdfFallback = path.join(cfg.paths.s, 'img', 'pdf.webp');
|
||||
await fs.promises.copyFile(pdfFallback, tmpFile).catch(async () => {
|
||||
// If the asset is missing, generate a red PDF-style placeholder matching the user's reference
|
||||
await this.spawn('magick', [
|
||||
'-size', '256x256', 'xc:#d32f2f', // Professional PDF Red
|
||||
'-gravity', 'center',
|
||||
'-fill', 'white',
|
||||
'-pointsize', '60',
|
||||
'-annotate', '0', 'PDF',
|
||||
tmpFile
|
||||
]).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.spawn('magick', [tmpFile, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, itemid + '.webp')]);
|
||||
await fs.promises.unlink(tmpFile).catch(_ => { });
|
||||
|
||||
@@ -124,13 +124,13 @@ export default {
|
||||
const actPage = +(page ?? 1);
|
||||
|
||||
// Support multiple MIME types (comma separated)
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
? (flashMimes.length > 0
|
||||
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
|
||||
: db`false`)
|
||||
: db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: db``;
|
||||
const eps = limit ?? cfg.websrv.eps;
|
||||
const excludedTags = session && exclude ? (exclude || []) : [];
|
||||
@@ -155,16 +155,15 @@ export default {
|
||||
select ta.item_id
|
||||
from tags_assign ta
|
||||
join tags t on t.id = ta.tag_id
|
||||
where lower(t.normalized) = ANY(${strictParams}::text[])
|
||||
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
|
||||
group by ta.item_id
|
||||
having count(distinct lower(t.normalized)) = ${strictParams.length}
|
||||
having count(distinct t.normalized) = ${terms.length}
|
||||
)`;
|
||||
} else {
|
||||
// Non-strict intersection Logic (AND for partials)
|
||||
// For each term, ensure there is AT LEAST one matching tag assigned to the item
|
||||
const conditions = terms.map(term => {
|
||||
const q = '%' + lib.slugify(term) + '%';
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
|
||||
});
|
||||
tagFilter = db`${conditions}`;
|
||||
}
|
||||
@@ -317,13 +316,13 @@ export default {
|
||||
}
|
||||
const mime = (rawMime ?? "");
|
||||
const itemid = rawItemid ? +rawItemid : null;
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
? (flashMimes.length > 0
|
||||
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
|
||||
: db`false`)
|
||||
: db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: db``;
|
||||
const excludedTags = exclude || [];
|
||||
|
||||
@@ -351,14 +350,13 @@ export default {
|
||||
select ta.item_id
|
||||
from tags_assign ta
|
||||
join tags t on t.id = ta.tag_id
|
||||
where lower(t.normalized) = ANY(${strictParams}::text[])
|
||||
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
|
||||
group by ta.item_id
|
||||
having count(distinct lower(t.normalized)) = ${strictParams.length}
|
||||
having count(distinct t.normalized) = ${terms.length}
|
||||
)`;
|
||||
} else {
|
||||
const conditions = terms.map(term => {
|
||||
const q = '%' + lib.slugify(term) + '%';
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
|
||||
});
|
||||
tagFilter = db`${conditions}`;
|
||||
}
|
||||
@@ -665,13 +663,13 @@ export default {
|
||||
}
|
||||
|
||||
// Support multiple MIME types (comma separated)
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
? (flashMimes.length > 0
|
||||
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
|
||||
: db`false`)
|
||||
: db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: db``;
|
||||
const excludedTags = session && exclude ? (exclude || []) : [];
|
||||
|
||||
@@ -714,14 +712,13 @@ export default {
|
||||
select ta.item_id
|
||||
from tags_assign ta
|
||||
join tags t on t.id = ta.tag_id
|
||||
where lower(t.normalized) = ANY(${strictParams}::text[])
|
||||
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
|
||||
group by ta.item_id
|
||||
having count(distinct lower(t.normalized)) = ${strictParams.length}
|
||||
having count(distinct t.normalized) = ${terms.length}
|
||||
)`;
|
||||
} else {
|
||||
const conditions = terms.map(term => {
|
||||
const q = '%' + lib.slugify(term) + '%';
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
|
||||
});
|
||||
tagFilter = db`${conditions}`;
|
||||
}
|
||||
@@ -850,6 +847,28 @@ export default {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
getComment: async (id, process = true) => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const comment = await db`
|
||||
SELECT
|
||||
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE c.id = ${id} AND c.is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
if (!comment.length) return null;
|
||||
return process ? (await processMentions(comment))[0] : comment[0];
|
||||
} catch (e) {
|
||||
console.error('[F0CKLIB] Error fetching comment:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSubscriptionStatus: async (userId, itemId) => {
|
||||
if (!userId || !itemId) return false;
|
||||
const tStart = Date.now();
|
||||
|
||||
@@ -4,27 +4,29 @@ export default (obj, word) => {
|
||||
return obj.map(tmp => {
|
||||
let rscore = 0
|
||||
, startat = 0
|
||||
, string = tmp.tag
|
||||
, cscore
|
||||
, score;
|
||||
for(let i = 0; i < word.length; i++) {
|
||||
const idxOf = string.toLowerCase().indexOf(word.toLowerCase()[i], startat);
|
||||
const stringNorm = (tmp.tag || "").normalize('NFC').toLowerCase();
|
||||
const wordNorm = (word || "").normalize('NFC').toLowerCase();
|
||||
|
||||
for(let i = 0; i < wordNorm.length; i++) {
|
||||
const idxOf = stringNorm.indexOf(wordNorm[i], startat);
|
||||
if(-1 === idxOf)
|
||||
return { score: 0 };
|
||||
if(startat === idxOf)
|
||||
cscore = 0.7;
|
||||
else {
|
||||
cscore = 0.1;
|
||||
if(string[idxOf - 1] === ' ')
|
||||
if(stringNorm[idxOf - 1] === ' ')
|
||||
cscore += 0.8;
|
||||
}
|
||||
if(string[idxOf] === word[i])
|
||||
if(stringNorm[idxOf] === wordNorm[i])
|
||||
cscore += 0.1;
|
||||
rscore += cscore;
|
||||
startat = idxOf + 1;
|
||||
}
|
||||
score = 0.5 * (rscore / string.length + rscore / word.length);
|
||||
if(word.toLowerCase()[0] === string.toLowerCase()[0] && score < 0.85)
|
||||
score = 0.5 * (rscore / stringNorm.length + rscore / wordNorm.length);
|
||||
if(wordNorm[0] === stringNorm[0] && score < 0.85)
|
||||
score += 0.15;
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads } from "../settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -84,9 +84,10 @@ export default (router, tpl) => {
|
||||
|
||||
const stamp = ~~(Date.now() / 1e3);
|
||||
|
||||
// F-015: Clean up stale non-KMSI sessions unused for 7 days (on login)
|
||||
await db`
|
||||
delete from user_sessions
|
||||
where last_action <= ${(Date.now() - 6048e5)}
|
||||
where last_used <= ${stamp - 604800}
|
||||
and kmsi = 0
|
||||
`;
|
||||
|
||||
@@ -578,7 +579,7 @@ export default (router, tpl) => {
|
||||
router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => {
|
||||
const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false';
|
||||
const registration_open = req.post.registration_open === 'on' ? 'true' : 'false';
|
||||
const min_tags = parseInt(req.post.min_tags) || 3;
|
||||
const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags));
|
||||
const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3);
|
||||
|
||||
await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
@@ -622,7 +623,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
WITH filtered_users AS (
|
||||
SELECT
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
|
||||
uo.avatar_file, uo.display_name,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -632,7 +633,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
SELECT
|
||||
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 'Legacy' as reg_method
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||
FROM items i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
|
||||
@@ -761,6 +762,40 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, mode, lock } = req.post;
|
||||
if (!user_id) throw new Error('Missing user_id');
|
||||
|
||||
const isLocked = lock === true || lock === 'true' || lock === 1;
|
||||
const targetMode = parseInt(mode, 10);
|
||||
|
||||
const updateData = { force_comment_display_mode: isLocked ? 1 : 0 };
|
||||
if (!isNaN(targetMode)) updateData.comment_display_mode = targetMode;
|
||||
|
||||
const result = await db`
|
||||
UPDATE user_options
|
||||
SET ${db(updateData)}
|
||||
WHERE user_id = ${+user_id}
|
||||
RETURNING user_id
|
||||
`;
|
||||
|
||||
if (!result.length) throw new Error('User options not found');
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, isLocked ? 'lock_user_layout' : 'unlock_user_layout', 'user', +user_id, { mode: targetMode });
|
||||
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: true,
|
||||
msg: 'User layout ' + (isLocked ? 'locked' : 'unlocked') + '.',
|
||||
force_comment_display_mode: isLocked ? 1 : 0,
|
||||
comment_display_mode: targetMode
|
||||
}));
|
||||
} catch (err) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.post;
|
||||
@@ -815,8 +850,11 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
ghostSlugs.add(finalSlug);
|
||||
|
||||
if (hall.custom_image) {
|
||||
const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${hall.slug}.webp`);
|
||||
const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${finalSlug}.webp`);
|
||||
// F-004 Security: Sanitize slugs before constructing file paths
|
||||
const safeSlug = path.basename(hall.slug);
|
||||
const safeFinalSlug = path.basename(finalSlug);
|
||||
const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${safeSlug}.webp`);
|
||||
const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${safeFinalSlug}.webp`);
|
||||
try {
|
||||
await fs.rename(oldPath, newPath);
|
||||
} catch (e) {
|
||||
@@ -1192,5 +1230,16 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
});
|
||||
});
|
||||
|
||||
// Chat Manager
|
||||
router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render('admin/chat', {
|
||||
session: req.session,
|
||||
totals: await lib.countf0cks(),
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default (router, tpl) => {
|
||||
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
|
||||
@@ -145,7 +145,7 @@ export default (router, tpl) => {
|
||||
const paginationHtml = tpl.render('snippets/pagination', data, req);
|
||||
const tAjaxRender = Date.now();
|
||||
|
||||
console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
|
||||
- getf0ck: ${tAjaxFetch - tAjaxStart}ms
|
||||
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
|
||||
- Render: ${tAjaxRender - tAjaxAux}ms`);
|
||||
|
||||
@@ -11,8 +11,17 @@ import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
|
||||
const allowedMimes = ["audio", "image", "video", "%"];
|
||||
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const metaCache = new Map();
|
||||
const MAX_META_CACHE = 2000;
|
||||
|
||||
export default router => {
|
||||
// Ensure cache table exists
|
||||
db`CREATE TABLE IF NOT EXISTS meta_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
data JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`.catch(err => console.error('[META-CACHE] Table creation failed:', err));
|
||||
|
||||
router.group(/^\/api\/v2/, group => {
|
||||
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
@@ -282,6 +291,8 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
|
||||
// Guests use cached entries from DB (populated by authenticated user requests).
|
||||
group.get(/\/meta\/fetch$/, lib.loggedin, async (req, res) => {
|
||||
if (!cfg.websrv.web_meta_extraction) {
|
||||
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
|
||||
@@ -290,6 +301,38 @@ export default router => {
|
||||
const url = req.url.qs.url;
|
||||
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
|
||||
|
||||
if (metaCache.has(url)) {
|
||||
return res.json({ success: true, meta: metaCache.get(url) });
|
||||
}
|
||||
|
||||
// Check DB cache for persistence across restarts
|
||||
try {
|
||||
const cached = await db`SELECT data FROM meta_cache WHERE url = ${url} LIMIT 1`;
|
||||
if (cached.length > 0) {
|
||||
const meta = cached[0].data;
|
||||
metaCache.set(url, meta); // update in-memory cache
|
||||
return res.json({ success: true, meta });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[META-CACHE] DB lookup failed:', err);
|
||||
}
|
||||
|
||||
const setCache = async (u, m) => {
|
||||
if (!m || !m.title) return;
|
||||
metaCache.set(u, m);
|
||||
if (metaCache.size > MAX_META_CACHE) {
|
||||
const first = metaCache.keys().next().value;
|
||||
metaCache.delete(first);
|
||||
}
|
||||
// Persist to DB
|
||||
try {
|
||||
await db`INSERT INTO meta_cache (url, data) VALUES (${u}, ${m})
|
||||
ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`;
|
||||
} catch (err) {
|
||||
console.error('[META-CACHE] DB save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (/\.(mp4|webm|mp3|ogg|opus|flac|m4a|mkv|jpg|jpeg|png|gif|webp|swf)$/i.test(url)) {
|
||||
return res.json({ success: false, msg: 'Metadata extraction skipped for direct media URLs' }, 400);
|
||||
}
|
||||
@@ -314,13 +357,15 @@ export default router => {
|
||||
if (oembedOut && oembedOut.trim()) {
|
||||
const data = JSON.parse(oembedOut);
|
||||
if (data.title) {
|
||||
const meta = {
|
||||
title: data.title,
|
||||
site_name: 'youtube.com',
|
||||
author: data.author_name || 'Unknown'
|
||||
};
|
||||
await setCache(url, meta);
|
||||
return res.json({
|
||||
success: true,
|
||||
meta: {
|
||||
title: data.title,
|
||||
site_name: 'youtube.com',
|
||||
author: data.author_name || 'Unknown'
|
||||
}
|
||||
meta
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -354,13 +399,15 @@ export default router => {
|
||||
}
|
||||
|
||||
if (title) {
|
||||
const meta = {
|
||||
title: title,
|
||||
site_name: lines[2] ? lines[2].trim() : 'Media Site',
|
||||
author: lines[1] ? lines[1].trim() : 'Unknown'
|
||||
};
|
||||
await setCache(url, meta);
|
||||
return res.json({
|
||||
success: true,
|
||||
meta: {
|
||||
title: title,
|
||||
site_name: lines[2] ? lines[2].trim() : 'Media Site',
|
||||
author: lines[1] ? lines[1].trim() : 'Unknown'
|
||||
}
|
||||
meta
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -402,6 +449,7 @@ export default router => {
|
||||
return res.json({ success: false, msg: 'Reddit bot protection encountered' }, 403);
|
||||
}
|
||||
|
||||
await setCache(url, meta);
|
||||
return res.json({ success: true, meta });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -663,7 +711,7 @@ export default router => {
|
||||
reply.success = true;
|
||||
reply.suggestions = search(q, searchString);
|
||||
} catch (err) {
|
||||
reply.error = err.msg;
|
||||
reply.error = 'Tag suggestion error';
|
||||
}
|
||||
|
||||
return res.json(reply);
|
||||
@@ -688,7 +736,7 @@ export default router => {
|
||||
`;
|
||||
return res.json({ success: true, suggestions: users });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, error: err.message, suggestions: [] });
|
||||
return res.json({ success: false, error: 'User suggestion error', suggestions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||
// These routes remain for other settings API endpoints
|
||||
@@ -443,6 +445,20 @@ export default router => {
|
||||
group.put(/\/font/, lib.loggedin, async (req, res) => {
|
||||
const { font } = req.post;
|
||||
|
||||
// F-023 Security: Validate font against actual files on disk
|
||||
// The font value is rendered into CSS url() in header.html, so it must be a real filename
|
||||
if (font) {
|
||||
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
|
||||
try {
|
||||
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
|
||||
if (!available.includes(font)) {
|
||||
return res.json({ success: false, msg: 'Invalid font selection' }, 400);
|
||||
}
|
||||
} catch {
|
||||
return res.json({ success: false, msg: 'Font directory unavailable' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
@@ -517,23 +533,25 @@ export default router => {
|
||||
|
||||
// Update Ruffle (Flash) preferences
|
||||
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
|
||||
const ruffle_volume = parseFloat(req.post.ruffle_volume);
|
||||
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
|
||||
const ruffle_volume = req.post.ruffle_volume !== undefined ? parseFloat(req.post.ruffle_volume) : undefined;
|
||||
|
||||
if (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1) {
|
||||
if (ruffle_volume !== undefined && (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1)) {
|
||||
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = { ruffle_background };
|
||||
if (ruffle_volume !== undefined) updateData.ruffle_volume = ruffle_volume;
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set ruffle_volume = ${ruffle_volume},
|
||||
ruffle_background = ${ruffle_background}
|
||||
set ${db(updateData)}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) {
|
||||
req.session.ruffle_volume = ruffle_volume;
|
||||
req.session.ruffle_background = ruffle_background;
|
||||
if (ruffle_volume !== undefined) req.session.ruffle_volume = ruffle_volume;
|
||||
}
|
||||
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
|
||||
} catch (e) {
|
||||
@@ -639,6 +657,62 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update comment display mode preference
|
||||
group.put(/\/comment_display_mode/, lib.loggedin, async (req, res) => {
|
||||
const mode = parseInt(req.post.mode, 10);
|
||||
if (isNaN(mode) || (mode !== 0 && mode !== 1)) {
|
||||
return res.json({ success: false, msg: 'Invalid mode' }, 400);
|
||||
}
|
||||
|
||||
// Check if mode is forced
|
||||
const forced = (await db`select force_comment_display_mode from user_options where user_id = ${+req.session.id}`)[0]?.force_comment_display_mode;
|
||||
if (forced) {
|
||||
return res.json({ success: false, msg: 'Comment layout is locked for your account.' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set comment_display_mode = ${mode}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.comment_display_mode = mode;
|
||||
return res.json({ success: true, mode }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update comment_display_mode error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update notification preferences (Consolidated Endpoint)
|
||||
group.post('/notifications', lib.loggedin, async (req, res) => {
|
||||
const { key, value } = req.post;
|
||||
const allowedKeys = ['receive_system_notifications', 'receive_user_notifications', 'do_not_disturb'];
|
||||
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return res.json({ success: false, msg: 'Invalid preference key' }, 400);
|
||||
}
|
||||
|
||||
const boolValue = value === true || value === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set ${db({ [key]: boolValue }, key)}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
if (req.session) req.session[key] = boolValue;
|
||||
|
||||
await db`SELECT pg_notify('profile_update', ${JSON.stringify({ user_id: req.session.id, [key]: boolValue })})`;
|
||||
|
||||
return res.json({ success: true, [key]: boolValue }, 200);
|
||||
} catch (e) {
|
||||
console.error(`Update notification preference (${key}) error:`, e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export default router => {
|
||||
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: isDuplicate ? 'Tag already exists' : err.message,
|
||||
msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export default router => {
|
||||
|
||||
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
return res.json({ success: false, msg: 'Failed to update rating' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,14 +54,13 @@ import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../se
|
||||
// Collect request body as buffer with debug logging
|
||||
const collectBody = (req) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[UPLOAD DEBUG] collectBody started');
|
||||
if (cfg.main.development) console.log('[UPLOAD DEBUG] collectBody started');
|
||||
const chunks = [];
|
||||
req.on('data', chunk => {
|
||||
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
||||
if (cfg.main.development) console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
req.on('error', err => {
|
||||
@@ -71,7 +70,7 @@ const collectBody = (req) => {
|
||||
|
||||
// Ensure stream is flowing
|
||||
if (req.isPaused()) {
|
||||
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
||||
if (cfg.main.development) console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
||||
req.resume();
|
||||
}
|
||||
});
|
||||
@@ -230,16 +229,11 @@ export default router => {
|
||||
|
||||
// Download YouTube thumbnail as our thumbnail
|
||||
try {
|
||||
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const tmpThumb = path.join(cfg.paths.tmp, `${itemid}_yt.jpg`);
|
||||
await queue.spawn('wget', ['-q', thumbUrl, '-O', tmpThumb]);
|
||||
await queue.spawn('magick', [tmpThumb, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, `${itemid}.webp`)]);
|
||||
await fs.unlink(tmpThumb).catch(() => {});
|
||||
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, isApprovalRequired);
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube thumbnail error:', err);
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
// Assign rating tag
|
||||
@@ -317,8 +311,8 @@ export default router => {
|
||||
|
||||
// Priority 2: Extract HTTP codes
|
||||
const httpCode = msg.match(/HTTP Error (\d+)/i)?.[1]
|
||||
|| msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|
||||
|| null;
|
||||
|| msg.match(/status code (\d{3})/i)?.[1]
|
||||
|| (msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] !== '537' ? msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] : null);
|
||||
if (httpCode) return `Download/Process failed (HTTP ${httpCode})`;
|
||||
|
||||
// Priority 3: Sanitize raw queue.spawn errors
|
||||
@@ -355,7 +349,7 @@ export default router => {
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath',
|
||||
'--merge-output-format', 'mp4'
|
||||
])).stdout.trim();
|
||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
||||
if (isInstagram) throw new Error(sanitizeError(err));
|
||||
@@ -367,9 +361,10 @@ export default router => {
|
||||
'--max-filesize', `${maxfilesize / 1024}k`,
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath'
|
||||
])).stdout.trim();
|
||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err2) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
|
||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
let referer = url;
|
||||
try {
|
||||
@@ -380,7 +375,7 @@ export default router => {
|
||||
} catch (e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'-s', '-S', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'--max-filesize', `${maxfilesize}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
|
||||
@@ -84,6 +84,10 @@ export default (router, tpl) => {
|
||||
if (!req.session) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
|
||||
}
|
||||
// F-007 Security: Block banned users from chatting
|
||||
if (req.session.banned) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'You are banned' }) });
|
||||
}
|
||||
|
||||
const message = (req.post?.message || '').trim();
|
||||
if (!message || message.length > MAX_MSG_LEN) {
|
||||
|
||||
@@ -61,6 +61,43 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single comment by ID
|
||||
router.get(/\/api\/comment\/(?<id>\d+)/, async (req, res) => {
|
||||
const id = req.params.id;
|
||||
|
||||
// Require login unless comments are public
|
||||
if (!req.session && cfg.main.hide_comments_from_public) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, message: "Unauthorized" })
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await f0cklib.getComment(id);
|
||||
if (!comment) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, message: "Comment not found" })
|
||||
});
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, comment })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, message: "Database error" })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Browse User Comments
|
||||
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
|
||||
const user = decodeURIComponent(req.params.user);
|
||||
@@ -207,7 +244,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("DEBUG: POST /api/comments");
|
||||
if (cfg.main.development) console.log("DEBUG: POST /api/comments");
|
||||
|
||||
// Use standard framework parsing
|
||||
const body = req.post || {};
|
||||
@@ -218,7 +255,7 @@ export default (router, tpl) => {
|
||||
? parseFloat(body.video_time)
|
||||
: null;
|
||||
|
||||
console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||
if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
|
||||
@@ -444,7 +481,7 @@ export default (router, tpl) => {
|
||||
router.post(/\/api\/comments\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const commentId = req.params.id;
|
||||
console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
|
||||
if (cfg.main.development) console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
|
||||
|
||||
try {
|
||||
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
|
||||
|
||||
@@ -32,6 +32,11 @@ export default (router, tpl) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
// F-031 Security: CSRF validation for destructive admin action
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,34 @@ import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
||||
*/
|
||||
export default (router) => {
|
||||
|
||||
// --- F-001 Security: Per-user rate limiter for proxy routes ---
|
||||
const proxyRateMap = new Map();
|
||||
const PROXY_RATE_LIMIT = 5000; // max requests per window
|
||||
const PROXY_RATE_WINDOW = 600000; // 10 minute window
|
||||
const proxyRateLimit = (req, res) => {
|
||||
if (!req.session) return true; // loggedin middleware handles auth; this is just a guard
|
||||
const key = req.session.id;
|
||||
const now = Date.now();
|
||||
let entry = proxyRateMap.get(key);
|
||||
if (!entry || now - entry.start > PROXY_RATE_WINDOW) {
|
||||
entry = { start: now, count: 0 };
|
||||
proxyRateMap.set(key, entry);
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count > PROXY_RATE_LIMIT) {
|
||||
res.reply({ code: 429, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Rate limit exceeded' }) });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// Periodic cleanup to prevent memory leak
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of proxyRateMap) {
|
||||
if (now - v.start > PROXY_RATE_WINDOW * 2) proxyRateMap.delete(k);
|
||||
}
|
||||
}, PROXY_RATE_WINDOW * 2);
|
||||
|
||||
/**
|
||||
* Helper to fetch data (JSON or Buffer) using curl if a proxy is configured.
|
||||
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
|
||||
@@ -39,7 +67,8 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/:tid
|
||||
// Proxies 4chan thread JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, tid } = req.params || {};
|
||||
|
||||
if (!board || !tid) {
|
||||
@@ -84,7 +113,7 @@ export default (router) => {
|
||||
|
||||
// POST /api/v2/scroller/external/rehost-meta
|
||||
// Given item IDs, return their metadata (username, avatar, timestamp)
|
||||
router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, async (req, res) => {
|
||||
router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, lib.loggedin, async (req, res) => {
|
||||
const ids = (req.post?.ids || '').split(',').map(Number).filter(n => n > 0);
|
||||
if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
|
||||
@@ -96,7 +125,8 @@ export default (router) => {
|
||||
uo.avatar_file, uo.avatar,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${ratingTagIds}::int[])
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id,
|
||||
(SELECT COUNT(*) FROM comments WHERE comments.item_id = i.id AND comments.is_deleted = false) AS comment_count
|
||||
FROM items i
|
||||
LEFT JOIN "user" u ON u."user" = i.username
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -113,7 +143,8 @@ export default (router) => {
|
||||
avatar: r.avatar_file ? `/a/${r.avatar_file}` : (r.avatar ? `/t/${r.avatar}.webp` : '/a/default.png'),
|
||||
stamp: r.stamp,
|
||||
rating_class,
|
||||
rating_label
|
||||
rating_label,
|
||||
comment_count: +r.comment_count || 0
|
||||
};
|
||||
});
|
||||
return res.reply({
|
||||
@@ -128,7 +159,8 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/catalog
|
||||
// Proxies 4chan board catalog JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board } = req.params || {};
|
||||
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
|
||||
@@ -165,7 +197,8 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/find/:postno
|
||||
// Resolves a post number to its parent thread ID
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, postno } = req.params || {};
|
||||
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
|
||||
@@ -223,11 +256,25 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/media/:file
|
||||
// Proxies 4chan media — streams directly to client for fast playback start
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, async (req, res) => {
|
||||
const { board, file } = req.params || {};
|
||||
const url = `https://i.4cdn.org/${board}/${file}`;
|
||||
// F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy)
|
||||
const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4'];
|
||||
|
||||
const ext = file.split('.').pop();
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, file } = req.params || {};
|
||||
|
||||
// Validate file extension against whitelist
|
||||
const ext = (file.split('.').pop() || '').toLowerCase();
|
||||
if (!ALLOWED_MEDIA_EXTS.includes(ext)) {
|
||||
return res.reply({ code: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Disallowed file type' }) });
|
||||
}
|
||||
|
||||
// Validate filename doesn't contain path traversal
|
||||
if (file.includes('..') || file.includes('/') || file.includes('\\')) {
|
||||
return res.reply({ code: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Invalid filename' }) });
|
||||
}
|
||||
|
||||
const url = `https://i.4cdn.org/${board}/${file}`;
|
||||
const mimes = {
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||||
'gif': 'image/gif', 'webp': 'image/webp',
|
||||
@@ -275,6 +322,13 @@ export default (router) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc } = req.post || {};
|
||||
|
||||
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||
|
||||
// F-014 Security: Restrict rehost to 4chan media URLs only
|
||||
const is4chanUrl = /^https?:\/\/(i\.4cdn\.org|boards\.4cdn\.org)\//i.test(url)
|
||||
|| /\/api\/v2\/scroller\/external\/4chan\/[a-z0-9]+\/media\//i.test(url);
|
||||
if (!is4chanUrl) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Only 4chan media URLs are supported for rehosting' }) });
|
||||
}
|
||||
|
||||
const board = url.match(/boards\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|
||||
|| url.match(/i\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|
||||
@@ -327,6 +381,11 @@ export default (router) => {
|
||||
const repost = await queue.checkrepostsum(checksum);
|
||||
if (repost) {
|
||||
await fs.unlink(finalTmp).catch(() => {});
|
||||
// Auto-subscribe user to the existing item they attempted to rehost
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
|
||||
|
||||
return res.reply({
|
||||
code: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -342,6 +401,11 @@ export default (router) => {
|
||||
const phashMatch = await queue.checkrepostphash(phash);
|
||||
if (phashMatch) {
|
||||
await fs.unlink(finalTmp).catch(() => {});
|
||||
// Auto-subscribe user to the existing item they attempted to rehost (visual match)
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
|
||||
|
||||
return res.reply({
|
||||
code: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -377,6 +441,11 @@ export default (router) => {
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Automatically subscribe user to the new item
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
|
||||
|
||||
// Process thumbnail
|
||||
try {
|
||||
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||
@@ -458,7 +527,7 @@ export default (router) => {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: err.message })
|
||||
body: JSON.stringify({ success: false, msg: 'Rehost failed' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default (router, tpl) => {
|
||||
|
||||
// Hall Thumbnail Route
|
||||
router.get(/^\/hall_image\/(?<hallSlug>.+)$/, async (req, res) => {
|
||||
const hallSlug = decodeURIComponent(req.params.hallSlug);
|
||||
const hallSlug = path.basename(decodeURIComponent(req.params.hallSlug));
|
||||
const mode = +(req.url.qs?.m ?? 0);
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
|
||||
|
||||
@@ -241,8 +241,8 @@ export default (router, tpl) => {
|
||||
data.total = 0;
|
||||
data.success = true;
|
||||
if (!data.link) {
|
||||
if (req.params.hall) data.link = { main: '/h/' + req.params.hall + '/', path: 'p/', suffix: '' };
|
||||
else if (req.params.tag) data.link = { main: '/tag/' + req.params.tag + '/', path: 'p/', suffix: '' };
|
||||
if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' };
|
||||
else if (req.params.tag) data.link = { main: '/tag/' + encodeURIComponent(req.params.tag) + '/', path: 'p/', suffix: '' };
|
||||
else data.link = { main: '/', path: 'p/', suffix: '' };
|
||||
}
|
||||
data.tmp = data.tmp || {};
|
||||
|
||||
@@ -35,6 +35,11 @@ export default (router, tpl) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
// F-031 Security: CSRF validation for destructive admin action
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,170 +38,8 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Approval Queue (Ported/Shared from Admin)
|
||||
// Approval Queue (View only — GET is safe, no state change)
|
||||
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
// Quick Approve Action
|
||||
if (req.url.qs?.id) {
|
||||
const id = +req.url.qs.id;
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
||||
from "items" i
|
||||
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
||||
where i.id = ${id} and i.active = false
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
return res.reply({ body: `f0ck ${id}: f0ck not found` });
|
||||
}
|
||||
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
// ACTION: Approve
|
||||
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
|
||||
// This prevents duplicate webhooks from double-clicks or race conditions.
|
||||
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
|
||||
|
||||
if (result.count === 1) {
|
||||
await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
|
||||
|
||||
// Notify User (WebSocket/Internal)
|
||||
try {
|
||||
const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, item_id)
|
||||
VALUES (${uploader[0].id}, 'approve', 0, ${id})
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||
}
|
||||
|
||||
// Push to Discord Webhook (Direct)
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
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) => {
|
||||
if (resDiscord.statusCode >= 400) {
|
||||
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
||||
}
|
||||
});
|
||||
reqDiscord.on('error', (err) => {
|
||||
console.error('[MOD APPROVE] Webhook failed:', err);
|
||||
});
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
||||
}
|
||||
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
||||
const clients = await Promise.all(router.self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: id,
|
||||
dest: f0ck[0].dest,
|
||||
mime: f0ck[0].mime,
|
||||
username: f0ck[0].username,
|
||||
tag_id: f0ck[0].tag_id,
|
||||
is_oc: !!f0ck[0].is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Move files to public location
|
||||
const movePaths = [
|
||||
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||
];
|
||||
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
await fs.access(p.b);
|
||||
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||
|
||||
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.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
|
||||
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||
|
||||
await moveSafe(p.b, bDst);
|
||||
await moveSafe(p.t, tDst);
|
||||
|
||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||
await moveSafe(blurSrc, blurDst);
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await moveSafe(p.ca, caDst);
|
||||
}
|
||||
break;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
return res.writeHead(302, { "Location": `/${id}` }).end();
|
||||
}
|
||||
|
||||
// View Queue
|
||||
const page = +req.url.qs.page || 1;
|
||||
const limit = 20;
|
||||
@@ -267,10 +105,190 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Deny / Delete Item
|
||||
router.get(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
|
||||
if (!req.url.qs?.id) return res.reply({ success: false, msg: "No ID provided" });
|
||||
const id = +req.url.qs.id;
|
||||
// F-005 Security: Approve action — POST with CSRF protection
|
||||
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
if (!id) {
|
||||
const body = JSON.stringify({ success: false, msg: 'No ID provided' });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
||||
from "items" i
|
||||
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
||||
where i.id = ${id} and i.active = false
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
// ACTION: Approve
|
||||
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
|
||||
// This prevents duplicate webhooks from double-clicks or race conditions.
|
||||
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
|
||||
|
||||
if (result.count === 1) {
|
||||
await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
|
||||
|
||||
// Notify User (WebSocket/Internal)
|
||||
try {
|
||||
const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, item_id)
|
||||
VALUES (${uploader[0].id}, 'approve', 0, ${id})
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||
}
|
||||
|
||||
// Push to Discord Webhook (Direct)
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
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) => {
|
||||
if (resDiscord.statusCode >= 400) {
|
||||
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
||||
}
|
||||
});
|
||||
reqDiscord.on('error', (err) => {
|
||||
console.error('[MOD APPROVE] Webhook failed:', err);
|
||||
});
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
||||
}
|
||||
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
||||
const clients = await Promise.all(router.self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: id,
|
||||
dest: f0ck[0].dest,
|
||||
mime: f0ck[0].mime,
|
||||
username: f0ck[0].username,
|
||||
tag_id: f0ck[0].tag_id,
|
||||
is_oc: !!f0ck[0].is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Move files to public location
|
||||
const movePaths = [
|
||||
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||
];
|
||||
|
||||
const isYouTube = f0ck[0].mime === 'video/youtube';
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
if (isYouTube) {
|
||||
await fs.access(p.t);
|
||||
} else {
|
||||
await fs.access(p.b);
|
||||
}
|
||||
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||
|
||||
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) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
|
||||
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||
|
||||
if (!isYouTube) {
|
||||
await moveSafe(p.b, bDst);
|
||||
}
|
||||
await moveSafe(p.t, tDst);
|
||||
|
||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||
await moveSafe(blurSrc, blurDst);
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await moveSafe(p.ca, caDst);
|
||||
}
|
||||
break;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
return res.writeHead(302, { "Location": `/${id}` }).end();
|
||||
});
|
||||
|
||||
// F-005 Security: Deny action — POST with CSRF protection
|
||||
router.post(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
if (!id) {
|
||||
const body = JSON.stringify({ success: false, msg: 'No ID provided' });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${id} limit 1`;
|
||||
if (f0ck.length > 0) {
|
||||
@@ -339,7 +357,7 @@ export default (router, tpl) => {
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const reason = req.url.qs?.reason || "Denied by moderator";
|
||||
const reason = req.post?.reason || "Denied by moderator";
|
||||
|
||||
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
|
||||
|
||||
@@ -541,8 +559,14 @@ export default (router, tpl) => {
|
||||
// Supports /mod/pending/b/filename.ext (Binaries)
|
||||
// Supports /mod/pending/t/id.webp (Thumbnails)
|
||||
router.get(/^\/mod\/pending\/(?<type>[btca])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
|
||||
const { type, file } = req.params;
|
||||
const filePath = path.join(cfg.paths.pending, type, file);
|
||||
const { type } = req.params;
|
||||
// F-003 Security: Sanitize file parameter to prevent path traversal
|
||||
const file = path.basename(req.params.file);
|
||||
const baseDir = path.resolve(cfg.paths.pending, type);
|
||||
const filePath = path.resolve(baseDir, file);
|
||||
if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
|
||||
return res.writeHead(403).end('Forbidden');
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
@@ -552,7 +576,8 @@ export default (router, tpl) => {
|
||||
const mimeType = {
|
||||
'mp4': 'video/mp4', 'webm': 'video/webm',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp',
|
||||
'pdf': 'application/pdf'
|
||||
}[ext] || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
@@ -577,7 +602,7 @@ export default (router, tpl) => {
|
||||
(await import('fs')).createReadStream(filePath).pipe(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.code !== 'ENOENT') console.error(err);
|
||||
res.writeHead(404).end('File not found');
|
||||
}
|
||||
});
|
||||
@@ -586,10 +611,15 @@ export default (router, tpl) => {
|
||||
// Supports /mod/deleted/b/filename.ext (Binaries)
|
||||
// Supports /mod/deleted/t/id.webp (Thumbnails)
|
||||
router.get(/^\/mod\/deleted\/(?<type>[bt])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
|
||||
const file = decodeURIComponent(req.params.file);
|
||||
// F-003 Security: Sanitize file parameter to prevent path traversal
|
||||
const file = path.basename(decodeURIComponent(req.params.file));
|
||||
const type = req.params.type; // 'b' or 't'
|
||||
console.log(`[MOD_STREAM] Request: type=${type}, file=${file}, range=${req.headers.range || 'none'}`);
|
||||
const filePath = path.join(cfg.paths.deleted, type, file);
|
||||
const baseDir = path.resolve(cfg.paths.deleted, type);
|
||||
const filePath = path.resolve(baseDir, file);
|
||||
if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
|
||||
return res.writeHead(403).end('Forbidden');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
@@ -599,7 +629,8 @@ export default (router, tpl) => {
|
||||
const mimeType = {
|
||||
'mp4': 'video/mp4', 'webm': 'video/webm',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp',
|
||||
'pdf': 'application/pdf'
|
||||
}[ext] || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
@@ -624,7 +655,7 @@ export default (router, tpl) => {
|
||||
(await import('fs')).createReadStream(filePath).pipe(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.code !== 'ENOENT') console.error(err);
|
||||
res.writeHead(404).end('File not found');
|
||||
}
|
||||
});
|
||||
@@ -656,7 +687,7 @@ export default (router, tpl) => {
|
||||
const body = JSON.stringify({ success: true, count });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
} catch (err) {
|
||||
const body = JSON.stringify({ success: false, msg: err.message });
|
||||
const body = JSON.stringify({ success: false, msg: 'Purge failed' });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,20 +10,26 @@ const activeTabs = new Map(); // sessionId -> tabId
|
||||
function broadcastChatPresence() {
|
||||
const seen = new Set();
|
||||
const users = [];
|
||||
const guestIps = new Set();
|
||||
for (const client of clients) {
|
||||
if (client.userId && !seen.has(client.userId)) {
|
||||
seen.add(client.userId);
|
||||
users.push({
|
||||
username: client.username,
|
||||
display_name: client.display_name,
|
||||
avatar_file: client.avatar_file,
|
||||
avatar: client.avatar,
|
||||
username_color: client.username_color
|
||||
});
|
||||
if (client.userId) {
|
||||
if (!seen.has(client.userId)) {
|
||||
seen.add(client.userId);
|
||||
users.push({
|
||||
username: client.username,
|
||||
display_name: client.display_name,
|
||||
avatar_file: client.avatar_file,
|
||||
avatar: client.avatar,
|
||||
username_color: client.username_color
|
||||
});
|
||||
}
|
||||
} else if (client.ip) {
|
||||
guestIps.add(client.ip);
|
||||
}
|
||||
}
|
||||
const guestCount = guestIps.size;
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat_presence', data: { users } });
|
||||
client.send({ type: 'global_chat_presence', data: { users, guestCount } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +47,16 @@ db.listen('notifications', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
const userId = data.user_id;
|
||||
const SYSTEM_TYPES = ['upload_success', 'upload_error'];
|
||||
const USER_TYPES = ['comment', 'comment_reply', 'mention', 'subscription', 'upload_comment'];
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) {
|
||||
// Do Not Disturb takes absolute priority for standard notifications
|
||||
if (client.do_not_disturb === true) continue;
|
||||
|
||||
if (SYSTEM_TYPES.includes(data.type) && client.receive_system_notifications === false) continue;
|
||||
if (USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
|
||||
client.send({ type: 'notify', data });
|
||||
}
|
||||
}
|
||||
@@ -73,6 +86,11 @@ db.listen('profile_update', (payload) => {
|
||||
const data = JSON.parse(payload);
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.user_id) {
|
||||
// Sync notification preferences to client object for real-time filtering
|
||||
if (data.receive_system_notifications !== undefined) client.receive_system_notifications = data.receive_system_notifications;
|
||||
if (data.receive_user_notifications !== undefined) client.receive_user_notifications = data.receive_user_notifications;
|
||||
if (data.do_not_disturb !== undefined) client.do_not_disturb = data.do_not_disturb;
|
||||
|
||||
client.send({ type: 'profile_update', data });
|
||||
}
|
||||
}
|
||||
@@ -217,6 +235,9 @@ db.listen('private_message', (payload) => {
|
||||
// Only send to the recipient — sender already knows they sent it
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.recipient_id) {
|
||||
// Silenced by DND
|
||||
if (client.do_not_disturb === true) continue;
|
||||
|
||||
client.send({ type: 'private_message', data: {
|
||||
id: data.id,
|
||||
sender_id: data.sender_id,
|
||||
@@ -293,6 +314,19 @@ db.listen('global_chat_background', (payload) => {
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat_background error:', err));
|
||||
|
||||
// Global listener for rethumb live updates
|
||||
db.listen('rethumb', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting rethumb (id: ${data.item_id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'rethumb', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Rethumb broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen rethumb error:', err));
|
||||
|
||||
// Global listener for chat topic changes
|
||||
db.listen('global_chat_topic', (payload) => {
|
||||
try {
|
||||
@@ -391,7 +425,15 @@ export default (router, tpl) => {
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
LEFT JOIN items i ON n.item_id = i.id
|
||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve')
|
||||
OR (
|
||||
${req.session.do_not_disturb !== true} AND (
|
||||
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
|
||||
OR (n.type IN ('comment', 'comment_reply', 'mention', 'subscription', 'upload_comment') AND ${req.session.receive_user_notifications !== false})
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report'))
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 1000
|
||||
`;
|
||||
@@ -510,8 +552,12 @@ export default (router, tpl) => {
|
||||
avatar_file: req.session?.avatar_file || null,
|
||||
avatar: req.session?.avatar || null,
|
||||
username_color: req.session?.username_color || null,
|
||||
receive_system_notifications: req.session?.receive_system_notifications !== false,
|
||||
receive_user_notifications: req.session?.receive_user_notifications !== false,
|
||||
do_not_disturb: req.session?.do_not_disturb === true,
|
||||
sessionId,
|
||||
tabId,
|
||||
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
||||
send: (data) => {
|
||||
try {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
@@ -315,7 +315,7 @@ export default (router, tpl) => {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, items: [], error: e.message })
|
||||
body: JSON.stringify({ success: false, items: [], error: 'Feed error' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,10 +68,10 @@ export default (router, tpl) => {
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where lower("tags".tag) in (${db(lowerTags)})
|
||||
where "tags".normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${tags}::text[]) AS x))
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
having count(distinct lower("tags".tag)) = ${lowerTags.length}
|
||||
having count(distinct "tags".normalized) = ${tags.length}
|
||||
) sub
|
||||
`;
|
||||
total = countResult.length > 0 ? countResult[0].total : 0;
|
||||
@@ -85,10 +85,10 @@ export default (router, tpl) => {
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where lower("tags".tag) in (${db(lowerTags)})
|
||||
where "tags".normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${tags}::text[]) AS x))
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
having count(distinct lower("tags".tag)) = ${lowerTags.length}
|
||||
having count(distinct "tags".normalized) = ${tags.length}
|
||||
order by "items".id desc
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
@@ -119,26 +119,34 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
else {
|
||||
total = (await db`
|
||||
select count(*) as total
|
||||
from "tags"
|
||||
left join "tags_assign" on "tags_assign".tag_id = "tags".id
|
||||
left join "items" on "items".id = "tags_assign".item_id
|
||||
where "tags".tag ilike ${'%' + tag + '%'}
|
||||
group by "items".id, "tags".tag
|
||||
`).length;
|
||||
const q = '%' + tag + '%';
|
||||
|
||||
const countResult = await db`
|
||||
select count(*) as total from (
|
||||
select 1
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where ("tags".tag ilike ${q} or "tags".normalized like '%' || slugify(${tag}) || '%')
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
) sub
|
||||
`;
|
||||
total = countResult.length > 0 ? parseInt(countResult[0].total) : 0;
|
||||
|
||||
const pages = +Math.ceil(total / _eps);
|
||||
const act_page = Math.min(pages, page || 1);
|
||||
const offset = Math.max(0, (act_page - 1) * _eps);
|
||||
|
||||
const rows = await db`
|
||||
select "items".id, "items".username, "items".mime, "tags".tag
|
||||
from "tags"
|
||||
left join "tags_assign" on "tags_assign".tag_id = "tags".id
|
||||
left join "items" on "items".id = "tags_assign".item_id
|
||||
where "tags".tag ilike ${'%' + tag + '%'} and "items".active = true
|
||||
group by "items".id, "tags".tag
|
||||
select "items".id, "items".username, "items".mime, min("tags".tag) as tag
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where ("tags".tag ilike ${q} or "tags".normalized like '%' || slugify(${tag}) || '%')
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
order by "items".id desc
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
`;
|
||||
|
||||
@@ -38,7 +38,7 @@ export default (router, tpl) => {
|
||||
res.setHeader('Expires', '0');
|
||||
res.setHeader('Surrogate-Control', 'no-store');
|
||||
|
||||
console.log('Rendering settings. Excluded tags:', excluded_tags);
|
||||
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('settings', {
|
||||
|
||||
@@ -21,7 +21,7 @@ export default (router, tpl) => {
|
||||
const offset = (page - 1) * eps;
|
||||
|
||||
try {
|
||||
console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
|
||||
if (cfg.main.development) console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
|
||||
|
||||
const countRes = await db`
|
||||
SELECT count(*) as total
|
||||
@@ -41,7 +41,7 @@ export default (router, tpl) => {
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ${eps} OFFSET ${offset}
|
||||
`;
|
||||
console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
|
||||
if (cfg.main.development) console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
|
||||
@@ -157,6 +157,25 @@ export default (router, tpl) => {
|
||||
data.hidePagination = true;
|
||||
data.session = req.session ? { ...req.session } : false;
|
||||
|
||||
// Precompute boolean helpers for template @if() — must match index.mjs pattern
|
||||
if (data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
|
||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||
data.item_username_lower = (item.username || '').toLowerCase();
|
||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
}
|
||||
|
||||
// Precompute hall display
|
||||
if (data.item?.halls?.length) {
|
||||
data.item.primaryHall = data.item.halls[0];
|
||||
@@ -168,17 +187,26 @@ export default (router, tpl) => {
|
||||
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
const useLegacy = req.session
|
||||
? (req.session.use_new_layout === false)
|
||||
: (cfg.websrv.default_layout === 'legacy');
|
||||
const sort = useLegacy ? 'old' : 'new';
|
||||
data.comments = await f0cklib.getComments(req.params.itemid, sort, false);
|
||||
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
data.commentsJSON = Buffer.from(JSON.stringify(data.comments || [])).toString('base64');
|
||||
|
||||
// xD Score
|
||||
const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
|
||||
// Comments loaded async by client
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
} else {
|
||||
data.comments = [];
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = Buffer.from('[]').toString('base64');
|
||||
data.commentsJSON = null;
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
|
||||
return res.reply({ body: tpl.render('item', data, req) });
|
||||
@@ -188,11 +216,13 @@ export default (router, tpl) => {
|
||||
router.get(/^\/user_hall_image\/(?<userId>\d+)\/(?<slug>.+)$/, async (req, res) => {
|
||||
const userId = +req.params.userId;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
// F-016 Security: Sanitize slug to prevent path traversal
|
||||
const safeSlug = path.basename(slug);
|
||||
const mode = +(req.url.qs?.m ?? 0);
|
||||
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
const customPath = path.join(CUSTOM_DIR, `u_${userId}_${slug}.webp`);
|
||||
const customPath = path.join(CUSTOM_DIR, `u_${userId}_${safeSlug}.webp`);
|
||||
|
||||
try {
|
||||
// 1. Serve custom image if present
|
||||
@@ -207,7 +237,7 @@ export default (router, tpl) => {
|
||||
} catch (_) { /* no custom image */ }
|
||||
|
||||
// 2. Check mosaic cache
|
||||
const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
|
||||
const hash = createHash('md5').update(`uh_${userId}_${safeSlug}_${mode}`).digest('hex');
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
|
||||
try {
|
||||
await fs.access(cachePath);
|
||||
@@ -316,8 +346,10 @@ export default (router, tpl) => {
|
||||
const result = await f0cklib.deleteUserHall(targetUserId, slug);
|
||||
|
||||
// Clean up custom image if it exists
|
||||
// F-016 Security: Sanitize slug to prevent path traversal in file deletion
|
||||
const safeSlug = path.basename(slug);
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${slug}.webp`)).catch(() => {});
|
||||
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${safeSlug}.webp`)).catch(() => {});
|
||||
|
||||
return res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
@@ -380,12 +412,14 @@ export default (router, tpl) => {
|
||||
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
|
||||
}
|
||||
|
||||
// F-016 Security: Sanitize slug to prevent path traversal in file deletion
|
||||
const safeSlug = path.basename(slug);
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${slug}.webp`)).catch(() => {});
|
||||
await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${safeSlug}.webp`)).catch(() => {});
|
||||
// Clear mosaic cache entries for all modes
|
||||
for (const m of [0, 1, 2]) {
|
||||
const h = createHash('md5').update(`uh_${req.session.id}_${slug}_${m}`).digest('hex');
|
||||
const h = createHash('md5').update(`uh_${req.session.id}_${safeSlug}_${m}`).digest('hex');
|
||||
await fs.unlink(path.join(CACHE_DIR, `${h}.webp`)).catch(() => {});
|
||||
}
|
||||
await db`UPDATE user_halls SET custom_image = false WHERE id = ${hall.id}`;
|
||||
|
||||
@@ -52,7 +52,8 @@ export default new class {
|
||||
*/
|
||||
async recordAttempt(ip, username, type, success) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
|
||||
if (!success) console.warn(`[SECURITY] Failed ${type} attempt: user=${username}, ip_hash=${ip_hash}`);
|
||||
else if (cfg.main.development) console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
|
||||
await db`
|
||||
insert into login_attempts (ip_hash, username, type, success)
|
||||
values (${ip_hash}, ${username?.toLowerCase() || null}, ${type}, ${success})
|
||||
@@ -66,7 +67,7 @@ export default new class {
|
||||
*/
|
||||
async clearAttempts(ip, username) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
|
||||
if (cfg.main.development) console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
|
||||
await db`
|
||||
delete from login_attempts
|
||||
where (ip_hash = ${ip_hash} OR username = ${username?.toLowerCase() || ''})
|
||||
@@ -92,7 +93,7 @@ export default new class {
|
||||
|
||||
const windowStart = new Date(Date.now() - windowMinutes * 60000);
|
||||
|
||||
console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
|
||||
if (cfg.main.development) console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
|
||||
|
||||
// Check attempts by IP
|
||||
const ipAttempts = await db`
|
||||
@@ -105,7 +106,10 @@ export default new class {
|
||||
`;
|
||||
|
||||
const ipCount = +ipAttempts[0].count;
|
||||
if (ipCount >= maxAttempts) return true;
|
||||
if (ipCount >= maxAttempts) {
|
||||
console.warn(`[SECURITY] Rate limit hit for ${type}: ip_hash=${ip_hash}, attempts=${ipCount}/${maxAttempts}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check attempts by username (if provided)
|
||||
if (username) {
|
||||
@@ -118,7 +122,10 @@ export default new class {
|
||||
and attempted_at > ${windowStart}
|
||||
`;
|
||||
const userCount = +userAttempts[0].count;
|
||||
if (userCount >= maxAttempts) return true;
|
||||
if (userCount >= maxAttempts) {
|
||||
console.warn(`[SECURITY] Rate limit hit for ${type}: user=${username}, attempts=${userCount}/${maxAttempts}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -8,12 +8,19 @@ let bypass_duplicate_check = false;
|
||||
let protect_files = false;
|
||||
let private_messages = true;
|
||||
let default_layout = 'modern';
|
||||
let enable_pdf = false;
|
||||
|
||||
export const getEnablePdf = () => enable_pdf;
|
||||
export const setEnablePdf = (val) => enable_pdf = !!val;
|
||||
|
||||
export const getManualApproval = () => manual_approval;
|
||||
export const setManualApproval = (val) => manual_approval = !!val;
|
||||
|
||||
export const getMinTags = () => min_tags;
|
||||
export const setMinTags = (val) => min_tags = parseInt(val) || 3;
|
||||
export const setMinTags = (val) => {
|
||||
const parsed = parseInt(val);
|
||||
min_tags = isNaN(parsed) ? 3 : Math.max(0, parsed);
|
||||
};
|
||||
|
||||
export const getRegistrationOpen = () => {
|
||||
if (cfg.websrv.open_registration_web_toggle === false) {
|
||||
|
||||
@@ -16,7 +16,8 @@ const regex = {
|
||||
imgur: /(?:https?:)?\/\/(\w+\.)?imgur\.com\/\S+/i,
|
||||
fourchan: /https?:\/\/i\.4cdn\.org\/(\w+)\/(\d+)\.(\w{3,4})/i,
|
||||
instagram: /(?:https?:\/\/www\.)?instagram\.com\S*?\/(?:p|reel)\/(\w{11})\/?/im,
|
||||
ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i
|
||||
ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i,
|
||||
vocaroo: /(?:https?:\/\/)?(?:www\.)?(?:vocaroo\.com|voca\.ro)\/([a-zA-Z0-9_-]+)/i
|
||||
};
|
||||
const pcUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
|
||||
const mediagroupids = new Set();
|
||||
@@ -444,7 +445,7 @@ export default async bot => {
|
||||
else if (link.match(regex.ph)) {
|
||||
try {
|
||||
// Added referer to fix fragment 404 errors, removed -vU to avoid exit code 100
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('Pornhub dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -455,7 +456,8 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.instagram)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Instagram dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -466,7 +468,7 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.imgur)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.warn(`[PARSER] Imgur Stage 1 (yt-dlp) failed: ${err.message}. Retrying with curl...`);
|
||||
|
||||
@@ -528,7 +530,7 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.yt)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('YouTube dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -539,7 +541,7 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.fourchan)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('4chan dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -550,7 +552,7 @@ export default async bot => {
|
||||
}
|
||||
else {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('General dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
|
||||
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout } from "./inc/settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf } from "./inc/settings.mjs";
|
||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||
import { createI18n } from "./inc/i18n.mjs";
|
||||
|
||||
@@ -234,7 +234,7 @@ process.on('uncaughtException', err => {
|
||||
|
||||
if (req.cookies.session) {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||
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
|
||||
@@ -352,8 +352,10 @@ process.on('uncaughtException', err => {
|
||||
embed_youtube_in_comments: user[0].embed_youtube_in_comments ?? (cfg.websrv.embed_youtube_in_comments !== false),
|
||||
hide_koepfe: user[0].hide_koepfe ?? false,
|
||||
language: (user[0].language && user[0].language.trim()) ? user[0].language.trim() : null,
|
||||
use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false)
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox')
|
||||
use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false),
|
||||
comment_display_mode: user[0].comment_display_mode ?? (cfg.websrv.default_comment_display_mode || 0),
|
||||
force_comment_display_mode: user[0].force_comment_display_mode ?? 0
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox', 'comment_display_mode', 'force_comment_display_mode')
|
||||
}
|
||||
on conflict ("user_id") do update set
|
||||
theme = excluded.theme,
|
||||
@@ -370,6 +372,8 @@ process.on('uncaughtException', err => {
|
||||
hide_koepfe = excluded.hide_koepfe,
|
||||
language = excluded.language,
|
||||
use_alternative_infobox = excluded.use_alternative_infobox,
|
||||
comment_display_mode = excluded.comment_display_mode,
|
||||
force_comment_display_mode = excluded.force_comment_display_mode,
|
||||
user_id = excluded.user_id
|
||||
`.catch(e => console.error('[MIDDLEWARE] Options sync failed:', e));
|
||||
}
|
||||
@@ -635,6 +639,10 @@ process.on('uncaughtException', err => {
|
||||
console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Set enable_pdf from config (pure config setting)
|
||||
setEnablePdf(!!cfg.enable_pdf);
|
||||
console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`);
|
||||
|
||||
// Load bypass_duplicate_check from config.json (static — not a DB setting)
|
||||
if (cfg.websrv.bypass_duplicate_check === true) {
|
||||
setBypassDuplicateCheck(true);
|
||||
@@ -736,6 +744,7 @@ process.on('uncaughtException', err => {
|
||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||
get private_messages() { return getPrivateMessages(); },
|
||||
get enable_pdf() { return getEnablePdf(); },
|
||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||
ts: Date.now(),
|
||||
get default_layout() { return getDefaultLayout(); },
|
||||
@@ -751,6 +760,7 @@ process.on('uncaughtException', err => {
|
||||
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
||||
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
||||
paths_images: cfg.websrv.paths?.images || '/b',
|
||||
default_comment_display_mode: cfg.websrv.default_comment_display_mode || 0,
|
||||
|
||||
get fonts() {
|
||||
try {
|
||||
@@ -818,7 +828,12 @@ process.on('uncaughtException', err => {
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
lang: perRequestLang,
|
||||
user_alternative_infobox: useAltInfobox
|
||||
user_alternative_infobox: useAltInfobox,
|
||||
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
||||
? req.session.comment_display_mode
|
||||
: (data && typeof data.comment_display_mode === 'number'
|
||||
? data.comment_display_mode
|
||||
: (cfg.websrv.default_comment_display_mode || 0))
|
||||
});
|
||||
|
||||
// Random brand image per-render
|
||||
@@ -853,4 +868,24 @@ process.on('uncaughtException', err => {
|
||||
|
||||
app.listen(cfg.websrv.port);
|
||||
|
||||
// F-015 Security: Periodic session cleanup — purge sessions unused for 30 days
|
||||
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6 hours
|
||||
|
||||
const cleanupStaleSessions = async () => {
|
||||
try {
|
||||
const cutoff = ~~(Date.now() / 1e3) - SESSION_TTL_SECONDS;
|
||||
const result = await db`DELETE FROM user_sessions WHERE last_used <= ${cutoff}`;
|
||||
if (result.count > 0) {
|
||||
console.log(`[SESSION CLEANUP] Purged ${result.count} stale sessions (unused >30 days)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SESSION CLEANUP] Failed:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Run once after startup (30s delay to let DB settle), then every 6 hours
|
||||
setTimeout(cleanupStaleSessions, 30_000);
|
||||
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||
|
||||
})();
|
||||
|
||||
@@ -148,6 +148,12 @@ export const handleRethumbUpload = async (req, res, itemId) => {
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
try {
|
||||
await db`SELECT pg_notify('rethumb', ${JSON.stringify({ item_id: item.id })})`;
|
||||
} catch (err) {
|
||||
console.error('[RETHUMB HANDLER] SSE notify error:', err);
|
||||
}
|
||||
|
||||
console.log('[RETHUMB HANDLER] Custom thumbnail applied to item', item.id);
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import cfg from "./inc/config.mjs";
|
||||
import queue from "./inc/queue.mjs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck } from "./inc/settings.mjs";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
|
||||
// Helper for JSON response
|
||||
@@ -171,6 +171,11 @@ export const handleUpload = async (req, res, self) => {
|
||||
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);
|
||||
}
|
||||
|
||||
// Reclassify audio-only MP4 containers (e.g. .m4a files detected as video/mp4)
|
||||
if (actualMime === 'video/mp4' || actualMime === 'video/quicktime') {
|
||||
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||
@@ -347,6 +352,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user