Update base

This commit is contained in:
2026-04-27 01:52:45 +02:00
parent b646107eb7
commit cdaf469a6d
31 changed files with 3766 additions and 418 deletions

View File

@@ -13,10 +13,12 @@
"mod": "mod",
"settings": "Einstellungen",
"logout": "Abmelden",
"notifications": "Nuttis",
"notifications": "Benachrichtigungen",
"mark_all_read": "Alle als gelesen markieren",
"no_notifications": "Keine neuen Nuttis",
"view_all_notifications": "Alle Nuttis anzeigen",
"no_notifications": "Keine neuen Benachrichtigungen",
"view_all_notifications": "Alle anzeigen",
"notif_tab_user": "Benutzer",
"notif_tab_system": "System",
"manage_subscriptions": "Abonnements verwalten",
"favorites": "Favoriten",
"direct_messages": "Direktnachrichten",
@@ -265,7 +267,7 @@
},
"comments": {
"write_comment": "Kommentar schreiben...",
"post": "Abschnalzen",
"post": "Senden",
"cancel": "Abbrechen"
},
"upload_btn": {
@@ -414,12 +416,13 @@
"loading": "Gespräche werden geladen…",
"decrypting": "Nachrichten werden entschlüsselt…",
"input_placeholder": "Nachricht schreiben…",
"send": "Abschnalzen"
"send": "Senden"
},
"profile": {
"message_btn": "Nachricht",
"legacy_record": "Legacy-Eintrag Erster Upload:",
"joined": "Beigetreten:",
"age_days": "{n} Tage",
"stat_comments": "Kommentare:",
"stat_tags": "Tags:",
"stat_halls": "Hallen:",
@@ -486,6 +489,8 @@
"months": "{n} Monaten",
"day": "{n} Tag",
"days": "{n} Tagen",
"week": "{n} Woche",
"weeks": "{n} Wochen",
"hour": "{n} Stunde",
"hours": "{n} Stunden",
"minute": "{n} Minute",
@@ -552,5 +557,92 @@
"slow_down": "Langsamer!",
"error_send": "Fehler beim Senden",
"network_error": "Netzwerkfehler"
},
"scroller": {
"just_now": "gerade eben",
"add": "Hinzufügen",
"update_preset": "Voreinstellung aktualisieren",
"update_preset_sub": "Änderungen speichern und Feed neu laden",
"no_presets": "Noch keine Voreinstellungen gespeichert.",
"copy_clipboard": "In Zwischenablage kopieren",
"copied": "Kopiert ✓",
"recent": "Kürzlich",
"nothing_found": "Nichts gefunden mit aktuellen Filtern",
"adjust_filters": "Filter anpassen",
"failed_load_comments": "Laden fehlgeschlagen",
"no_custom_emojis": "Keine eigenen Emojis",
"login_required": "Du musst eingeloggt sein, um Inhalte hinzuzufügen.",
"rehost_failed": "Rehost fehlgeschlagen: {msg}",
"chan_load_failed": "4chan-Thread konnte nicht geladen werden. Er ist möglicherweise archiviert oder enthält keine kompatiblen Medien.",
"fetch_failed": "Abruf fehlgeschlagen: {msg}",
"invalid_chan_url": "Bitte gib eine gültige 4chan-Thread-URL ein",
"chan_catalog_failed": "Katalog konnte nicht geladen werden",
"anonymous": "Anonym",
"back": "Zurück",
"settings": "Einstellungen",
"filters": "Filter",
"volume": "Lautstärke",
"reset_all": "Alles zurücksetzen",
"rating": "Bewertung",
"all": "Alle",
"untagged": "Ohne Tags",
"media_type": "Medientyp",
"video": "Video",
"image": "Bild",
"audio": "Audio",
"order": "Reihenfolge",
"random": "Zufall",
"newest": "Neueste",
"oldest": "Älteste",
"tags": "Tags",
"search_tags": "Tags suchen…",
"saved_presets": "Gespeicherte Voreinstellungen",
"save_preset": "Aktuelle Filter als Voreinstellung speichern",
"apply_reload": "Anwenden & Neu laden",
"chan_threads": "4chan-Fäden",
"thread_gallery": "Faden-Galerie",
"load_by_url": "Per URL laden",
"load": "Laden",
"browse_boards": "Bretter durchsuchen",
"go": "Los",
"search_threads": "Fäden suchen…",
"loading_catalog": "Katalog wird geladen…",
"appearance": "Darstellung",
"hide_ui": "UI verbergen",
"hide_ui_desc": "Blendet die Leiste und Aktionsknöpfe für volle Immersion aus",
"start_sound": "Mit Ton starten",
"start_sound_desc": "Automatisch Stummschaltung aufheben beim Öffnen",
"animated_bg": "Animierter Hintergrund",
"animated_bg_desc": "Live-Videoframes hinter dem Player; deaktivieren für statisches Vorschaubild",
"playback": "Wiedergabe",
"auto_next": "Auto-weiter",
"auto_next_desc": "Automatisch zum nächsten Inhalt wechseln, wenn das Medium endet",
"loops_before_next": "Schleifen vor Weiter",
"loops_before_next_desc": "Wie oft abspielen, bevor weitergeschaltet wird (Videos & Audio)",
"comments": "Kommentare",
"open": "Öffnen",
"loading": "Laden…",
"no_comments": "Noch keine Kommentare",
"write_comment": "Kommentar schreiben...",
"add_tag_placeholder": "Tag zu diesem Inhalt hinzufügen…",
"add_tag": "Tag hinzufügen",
"close": "Schließen",
"share": "Teilen",
"copy_link": "Link kopieren",
"send_dm": "Per DM senden",
"share_inbox": "An den Posteingang eines Benutzers teilen",
"search_user": "Benutzer suchen…",
"login_to_comment": "Einloggen zum Kommentieren",
"doomscroll": "Dunkelscrollen",
"favourite": "Favorit",
"view": "Ansehen",
"open_post": "Beitrag öffnen",
"already_added": "Bereits hinzugefügt",
"add_to_site": "Zur Seite hinzufügen",
"add_to_site_first": "Erst zur Seite hinzufügen, um Tags zu vergeben",
"left_hand": "Linkshändermodus",
"left_hand_desc": "Du weißt bescheid.",
"replying_to": "Antwort an {user}",
"reply": "Antworten"
}
}

View File

@@ -17,6 +17,8 @@
"mark_all_read": "Mark all read",
"no_notifications": "No new notifications",
"view_all_notifications": "View all notifications",
"notif_tab_user": "User",
"notif_tab_system": "System",
"manage_subscriptions": "manage subscriptions",
"favorites": "Favorites",
"direct_messages": "Direct Messages",
@@ -424,6 +426,7 @@
"message_btn": "✉ Message",
"legacy_record": "Legacy Record First Upload:",
"joined": "Joined:",
"age_days": "{n} days",
"stat_comments": "Comments:",
"stat_tags": "Tags:",
"stat_halls": "Halls:",
@@ -490,6 +493,8 @@
"months": "{n} months",
"day": "{n} day",
"days": "{n} days",
"week": "{n} week",
"weeks": "{n} weeks",
"hour": "{n} hour",
"hours": "{n} hours",
"minute": "{n} minute",
@@ -554,5 +559,92 @@
"slow_down": "Slow down!",
"error_send": "Error sending",
"network_error": "Network error"
},
"scroller": {
"just_now": "just now",
"add": "Add",
"update_preset": "Update & apply preset",
"update_preset_sub": "Save changes and reload feed now",
"no_presets": "No saved presets yet.",
"copy_clipboard": "Copy to clipboard",
"copied": "Copied ✓",
"recent": "Recent",
"nothing_found": "Nothing found with current filters",
"adjust_filters": "Adjust filters",
"failed_load_comments": "Failed to load",
"no_custom_emojis": "No custom emojis",
"login_required": "You must be logged in to add items.",
"rehost_failed": "Rehost failed: {msg}",
"chan_load_failed": "Could not load 4chan thread. It might be archived or have no compatible media.",
"fetch_failed": "Fetch failed: {msg}",
"invalid_chan_url": "Please enter a valid 4chan thread URL",
"chan_catalog_failed": "Failed to load catalog",
"anonymous": "Anonymous",
"back": "Back",
"settings": "Settings",
"filters": "Filters",
"volume": "Volume",
"reset_all": "Reset all",
"rating": "Rating",
"all": "All",
"untagged": "Untagged",
"media_type": "Media type",
"video": "Video",
"image": "Image",
"audio": "Audio",
"order": "Order",
"random": "Random",
"newest": "Newest",
"oldest": "Oldest",
"tags": "Tags",
"search_tags": "Search tags…",
"saved_presets": "Saved presets",
"save_preset": "Save current filters as preset",
"apply_reload": "Apply & Reload",
"chan_threads": "4chan Threads",
"thread_gallery": "Thread Gallery",
"load_by_url": "Load by URL",
"load": "Load",
"browse_boards": "Browse Boards",
"go": "Go",
"search_threads": "Search threads…",
"loading_catalog": "Loading catalog…",
"appearance": "Appearance",
"hide_ui": "Hide UI",
"hide_ui_desc": "Hides the top bar and action buttons for full immersion",
"start_sound": "Start with sound",
"start_sound_desc": "Automatically unmute when you open the scroller",
"animated_bg": "Animated background",
"animated_bg_desc": "Live video frames behind the player; disable for static thumbnail",
"playback": "Playback",
"auto_next": "Auto-next",
"auto_next_desc": "Automatically advance to the next item when media ends",
"loops_before_next": "Loops before next",
"loops_before_next_desc": "How many times to play before advancing (videos & audio)",
"comments": "Comments",
"open": "Open",
"loading": "Loading…",
"no_comments": "No comments yet",
"write_comment": "Write a comment...",
"add_tag_placeholder": "Add a tag to this item…",
"add_tag": "Add tag",
"close": "Close",
"share": "Share",
"copy_link": "Copy link",
"send_dm": "Send via DM",
"share_inbox": "Share to a user's inbox",
"search_user": "Search for a user…",
"login_to_comment": "Log in to comment",
"doomscroll": "doomscroll",
"favourite": "Favourite",
"view": "View",
"open_post": "Open post",
"already_added": "Already added",
"add_to_site": "Add to site",
"add_to_site_first": "Add to site first to tag",
"left_hand": "Left hand mode",
"left_hand_desc": "You know why.",
"replying_to": "Replying to {user}",
"reply": "Reply"
}
}

View File

@@ -17,6 +17,8 @@
"mark_all_read": "Alles als gelezen markeren",
"no_notifications": "Geen nieuwe meldingen",
"view_all_notifications": "Alle meldingen bekijken",
"notif_tab_user": "Gebruiker",
"notif_tab_system": "Systeem",
"manage_subscriptions": "abonnementen beheren",
"favorites": "Favorieten",
"direct_messages": "Directe Berichten",
@@ -420,6 +422,7 @@
"message_btn": "✉ bericht",
"legacy_record": "Legacy Record Eerste Upload:",
"joined": "Lid geworden:",
"age_days": "{n} dagen",
"stat_comments": "Opmerkingen:",
"stat_tags": "Tags:",
"stat_halls": "Hallen:",
@@ -486,6 +489,8 @@
"months": "{n} maanden",
"day": "{n} dag",
"days": "{n} dagen",
"week": "{n} week",
"weeks": "{n} weken",
"hour": "{n} uur",
"hours": "{n} uur",
"minute": "{n} minuut",
@@ -550,5 +555,92 @@
"slow_down": "Langzamer!",
"error_send": "Versturen mislukt",
"network_error": "Netwerkfout"
},
"scroller": {
"just_now": "zojuist",
"add": "Toevoegen",
"update_preset": "Voorinstelling bijwerken",
"update_preset_sub": "Wijzigingen opslaan en feed herladen",
"no_presets": "Nog geen opgeslagen voorinstellingen.",
"copy_clipboard": "Kopiëren naar klembord",
"copied": "Gekopieerd ✓",
"recent": "Recent",
"nothing_found": "Niets gevonden met huidige filters",
"adjust_filters": "Filters aanpassen",
"failed_load_comments": "Laden mislukt",
"no_custom_emojis": "Geen aangepaste emoji's",
"login_required": "Je moet ingelogd zijn om items toe te voegen.",
"rehost_failed": "Rehost mislukt: {msg}",
"chan_load_failed": "4chan-thread kon niet worden geladen. Het is mogelijk gearchiveerd of bevat geen compatibele media.",
"fetch_failed": "Ophalen mislukt: {msg}",
"invalid_chan_url": "Voer een geldige 4chan-thread-URL in",
"chan_catalog_failed": "Catalogus kon niet worden geladen",
"anonymous": "Anoniem",
"back": "Terug",
"settings": "Instellingen",
"filters": "Filters",
"volume": "Volume",
"reset_all": "Alles resetten",
"rating": "Beoordeling",
"all": "Alles",
"untagged": "Zonder tags",
"media_type": "Mediatype",
"video": "Video",
"image": "Afbeelding",
"audio": "Audio",
"order": "Volgorde",
"random": "Willekeurig",
"newest": "Nieuwste",
"oldest": "Oudste",
"tags": "Tags",
"search_tags": "Tags zoeken…",
"saved_presets": "Opgeslagen voorinstellingen",
"save_preset": "Huidige filters opslaan als voorinstelling",
"apply_reload": "Toepassen & Herladen",
"chan_threads": "4chan-threads",
"thread_gallery": "Thread-galerij",
"load_by_url": "Laden via URL",
"load": "Laden",
"browse_boards": "Borden doorbladeren",
"go": "Ga",
"search_threads": "Threads zoeken…",
"loading_catalog": "Catalogus laden…",
"appearance": "Uiterlijk",
"hide_ui": "UI verbergen",
"hide_ui_desc": "Verbergt de bovenste balk en actieknoppen voor volledige onderdompeling",
"start_sound": "Met geluid starten",
"start_sound_desc": "Automatisch demping opheffen bij openen",
"animated_bg": "Geanimeerde achtergrond",
"animated_bg_desc": "Live videoframes achter de speler; uitschakelen voor statische thumbnail",
"playback": "Afspelen",
"auto_next": "Auto-volgende",
"auto_next_desc": "Automatisch naar het volgende item gaan wanneer media eindigt",
"loops_before_next": "Lussen voor volgende",
"loops_before_next_desc": "Hoe vaak afspelen voordat wordt doorgegaan (video's & audio)",
"comments": "Opmerkingen",
"open": "Openen",
"loading": "Laden…",
"no_comments": "Nog geen opmerkingen",
"write_comment": "Schrijf een opmerking...",
"add_tag_placeholder": "Tag toevoegen aan dit item…",
"add_tag": "Tag toevoegen",
"close": "Sluiten",
"share": "Delen",
"copy_link": "Link kopiëren",
"send_dm": "Via DM versturen",
"share_inbox": "Delen naar de inbox van een gebruiker",
"search_user": "Zoek een gebruiker…",
"login_to_comment": "Inloggen om te reageren",
"doomscroll": "doomscroll",
"favourite": "Favoriet",
"view": "Bekijken",
"open_post": "Bericht openen",
"already_added": "Al toegevoegd",
"add_to_site": "Toevoegen aan site",
"add_to_site_first": "Eerst toevoegen aan site om tags te plaatsen",
"left_hand": "Linkshandige modus",
"left_hand_desc": "Je weet wel waarom.",
"replying_to": "Antwoord aan {user}",
"reply": "Antwoorden"
}
}

View File

@@ -17,6 +17,8 @@
"mark_all_read": "Alle als gelesen markieren",
"no_notifications": "Keine neuen Hinweise",
"view_all_notifications": "Alle Hinweise betrachten",
"notif_tab_user": "Benutzer",
"notif_tab_system": "System",
"manage_subscriptions": "Abonnements verwalten",
"favorites": "Favoriten",
"direct_messages": "Direktnachrichten",
@@ -387,7 +389,7 @@
},
"ranking": {
"title": "Rangliste",
"top_contributors": "Top-Beitragende",
"top_contributors": "Top Etikettierer",
"col_rank": "Rang",
"col_avatar": "Profilbild",
"col_username": "Benutzername",
@@ -424,6 +426,7 @@
"message_btn": "Nachricht",
"legacy_record": "Veralteter Datensatz Erste Aufladierung:",
"joined": "Beigetreten:",
"age_days": "{n} Tage",
"stat_comments": "Kommentare:",
"stat_tags": "Etiketten:",
"stat_halls": "Hallen:",
@@ -485,11 +488,13 @@
"timeago": {
"just_now": "gerade eben",
"year": "{n} Jahr",
"years": "{n} Jahre",
"years": "{n} Jahren",
"month": "{n} Monat",
"months": "{n} Monate",
"months": "{n} Monaten",
"day": "{n} Tag",
"days": "{n} Tage",
"days": "{n} Tagen",
"week": "{n} Woche",
"weeks": "{n} Wochen",
"hour": "{n} Stunde",
"hours": "{n} Stunden",
"minute": "{n} Minute",
@@ -556,5 +561,92 @@
"slow_down": "Gemach, gemach!",
"error_send": "Sendung fehlgeschlagen",
"network_error": "Netzwerkfehler"
},
"scroller": {
"just_now": "gerade eben",
"add": "Hinzufügen",
"update_preset": "Voreinstellung aktualisieren",
"update_preset_sub": "Änderungen speichern und Futterstrom neu laden",
"no_presets": "Noch keine Voreinstellungen gespeichert.",
"copy_clipboard": "In die Zwischenablage kopieren",
"copied": "Kopiert ✓",
"recent": "Kürzlich",
"nothing_found": "Nichts gefunden mit aktuellen Filtern",
"adjust_filters": "Filter anpassen",
"failed_load_comments": "Ladung fehlgeschlagen",
"no_custom_emojis": "Keine benutzerdefinierten Emojis",
"login_required": "Sie müssen angemeldet sein, um Elemente hinzuzufügen.",
"rehost_failed": "Umladierung fehlgeschlagen: {msg}",
"chan_load_failed": "Der Vierkanal-Faden konnte nicht geladen werden. Er ist möglicherweise archiviert oder enthält keine kompatiblen Medien.",
"fetch_failed": "Abruf fehlgeschlagen: {msg}",
"invalid_chan_url": "Bitte geben Sie eine gültige Vierkanal-Faden-Elfe ein",
"chan_catalog_failed": "Katalog konnte nicht geladen werden",
"anonymous": "Anonym",
"back": "Zurück",
"settings": "Einstellungen",
"filters": "Filter",
"volume": "Lautstärke",
"reset_all": "Alles zurücksetzen",
"rating": "Bewertung",
"all": "Alle",
"untagged": "Ohne Etiketten",
"media_type": "Medientyp",
"video": "Video",
"image": "Lichtbild",
"audio": "Tondatei",
"order": "Reihenfolge",
"random": "Zufall",
"newest": "Neueste",
"oldest": "Älteste",
"tags": "Etiketten",
"search_tags": "Etiketten suchen…",
"saved_presets": "Gespeicherte Voreinstellungen",
"save_preset": "Aktuelle Filter als Voreinstellung speichern",
"apply_reload": "Anwenden & Neu laden",
"chan_threads": "Vierkanal-Fäden",
"thread_gallery": "Faden-Galerie",
"load_by_url": "Per Elfe laden",
"load": "Laden",
"browse_boards": "Bretter durchstöbern",
"go": "Los",
"search_threads": "Fäden durchsuchen…",
"loading_catalog": "Katalog wird geladen…",
"appearance": "Darstellung",
"hide_ui": "Oberfläche verbergen",
"hide_ui_desc": "Verbirgt die Leiste und Aktionsknöpfe für volle Versenkung",
"start_sound": "Mit Ton beginnen",
"start_sound_desc": "Automatisch Stummschaltung aufheben beim Öffnen des Scrollers",
"animated_bg": "Belebter Hintergrund",
"animated_bg_desc": "Lebendige Videoframes hinter dem Abspieler; deaktivieren für ruhendes Vorschaubild",
"playback": "Wiedergabe",
"auto_next": "Selbsttätiges Weiter",
"auto_next_desc": "Selbsttätig zum nächsten Inhalt wechseln, wenn das Medium endet",
"loops_before_next": "Schleifen vor Weiter",
"loops_before_next_desc": "Wie oft abspielen, bevor weitergeschaltet wird (Videos & Tondateien)",
"comments": "Kommentare",
"open": "Öffnen",
"loading": "Ladung wird aufbereitet…",
"no_comments": "Noch keine Kommentare vorhanden",
"write_comment": "Schreiben Sie doch einen Kommentar...",
"add_tag_placeholder": "Etikett zu diesem Inhalt hinzufügen…",
"add_tag": "Etikett hinzufügen",
"close": "Schließen",
"share": "Teilen",
"copy_link": "Verknüpfung kopieren",
"send_dm": "Per Direktnachricht senden",
"share_inbox": "An den Posteingang eines Benutzers teilen",
"search_user": "Benutzer suchen…",
"login_to_comment": "Anmelden zum Kommentieren",
"doomscroll": "Verderbensrolle",
"favourite": "Favorit",
"view": "Ansehen",
"open_post": "Pfosten öffnen",
"already_added": "Bereits hinzugefügt",
"add_to_site": "Zur Weltnetzpräsenz hinzufügen",
"add_to_site_first": "Erst zur Weltnetzpräsenz hinzufügen, um Etiketten zu vergeben",
"left_hand": "Linkshändermodus",
"left_hand_desc": "Sie wissen schon wieso.",
"replying_to": "Antwort an {user}",
"reply": "Antworten"
}
}

View File

@@ -857,6 +857,60 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
}
});
router.post(/^\/api\/v2\/admin\/users\/reassign-uploads\/?$/, lib.auth, async (req, res) => {
try {
const { source_user_id, source_username, target_username } = req.post;
if (!source_user_id && !source_username) throw new Error('Missing source_user_id or source_username');
if (!target_username || !target_username.trim()) throw new Error('Missing target_username');
// Resolve source user (registered or ghost)
let sourceLogin, sourceUser;
if (source_user_id) {
const source = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+source_user_id} LIMIT 1`;
if (!source.length) throw new Error('Source user not found');
if (source[0].login === 'deleted_user') throw new Error('Cannot reassign uploads from the protected deleted_user account.');
sourceLogin = source[0].login;
sourceUser = source[0].user;
} else {
// Ghost/legacy user — just use the username directly
sourceLogin = source_username.trim();
sourceUser = source_username.trim();
}
// Resolve target user
const target = await db`SELECT id, login, "user" FROM "user" WHERE login ILIKE ${target_username.trim()} LIMIT 1`;
if (!target.length) throw new Error('Target user "' + target_username.trim() + '" not found');
const targetLogin = target[0].login;
const targetId = target[0].id;
if (source_user_id && +source_user_id === targetId) throw new Error('Source and target user are the same.');
// Reassign all items
const result = await db`
UPDATE items
SET username = ${targetLogin}
WHERE username ILIKE ${sourceLogin} OR username ILIKE ${sourceUser}
`;
// Log in audit
await audit.log(req.session.id, 'admin_reassign_uploads', 'user', source_user_id ? +source_user_id : null, {
source_login: sourceLogin,
target_login: targetLogin,
target_id: targetId,
count: result.count
});
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: true,
count: result.count,
msg: `Successfully reassigned ${result.count} uploads from ${sourceLogin} to ${targetLogin}.`
}));
} catch (err) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
router.post(/^\/api\/v2\/admin\/users\/bulk-delete-items\/?$/, lib.auth, async (req, res) => {
try {
const { user_id, username } = req.post;

View File

@@ -294,6 +294,43 @@ export default router => {
// Background processing block
(async () => {
const sanitizeError = (err) => {
if (!err) return `Failed to process ${url}`;
// Priority 1: meaningful error from stderr (yt-dlp/curl/etc)
if (err.stderr) {
const stderr = String(err.stderr).trim();
// yt-dlp specific patterns
const errorMatch = stderr.match(/ERROR:\s*(.+)$/m);
if (errorMatch) return errorMatch[1].trim();
// curl specific patterns
if (stderr.startsWith('curl: ')) return stderr;
// Fallback to last meaningful line of stderr
const lines = stderr.split('\n').map(l => l.trim()).filter(l => l && !l.includes('WARNING:'));
if (lines.length > 0) return lines[lines.length - 1];
}
const msg = String(err.message || '');
// 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;
if (httpCode) return `Download/Process failed (HTTP ${httpCode})`;
// Priority 3: Sanitize raw queue.spawn errors
if (msg.startsWith('Command \'')) {
const match = msg.match(/failed with code (\d+)/);
const code = match ? match[1] : '1';
return `Process failed (code ${code})`;
}
return msg || `Failed to process ${url}`;
};
try {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
@@ -303,16 +340,7 @@ export default router => {
const uuid = await queue.genuuid();
const isInstagram = /instagram\.com/i.test(url);
const dlError = (err) => {
if (!err) return `Failed to download from ${url}`;
const errStr = String(err.stderr || err.message || '');
const httpCode = errStr.match(/HTTP Error (\d+)/i)?.[1]
|| errStr.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|| null;
if (httpCode) return `Failed to download from ${url} (HTTP ${httpCode})`;
if (err.code != null) return `Failed to download from ${url} (code ${err.code})`;
return `Failed to download from ${url}`;
};
const dlError = (err) => sanitizeError(err);
let source;
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
@@ -330,7 +358,7 @@ export default router => {
])).stdout.trim();
} catch (err) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
if (isInstagram) throw err;
if (isInstagram) throw new Error(sanitizeError(err));
try {
source = (await queue.spawn('yt-dlp', [
@@ -365,7 +393,11 @@ export default router => {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
await queue.spawn('curl', curlArgs);
try {
await queue.spawn('curl', curlArgs);
} catch (err) {
throw new Error(sanitizeError(err));
}
const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
const extension = cfg.mimes[fallbackMime];
@@ -549,7 +581,7 @@ export default router => {
} catch (err) {
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
// Error notification
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: err.message })})`;
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: sanitizeError(err) })})`;
}
})();
}

467
src/inc/routes/external.mjs Normal file
View File

@@ -0,0 +1,467 @@
import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import queue from "../queue.mjs";
import { promises as fs } from "fs";
import path from "path";
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
/**
* external.mjs — External source handlers (4chan threads, etc.)
*/
export default (router) => {
/**
* 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.
*/
async function fetchWithProxy(url, asBuffer = false) {
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '30',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
if (asBuffer) return stdout;
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) {
console.error('[EXTERNAL] Non-JSON response from', url, '— first 200 chars:', text.slice(0, 200));
throw new Error('Expected JSON but got non-JSON response');
}
return JSON.parse(text);
}
// 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) => {
const { board, tid } = req.params || {};
if (!board || !tid) {
console.error('[EXTERNAL] Missing board or tid:', req.params);
return res.reply({ code: 400, body: JSON.stringify({ success: false, error: 'invalid_parameters' }) });
}
try {
const url = `https://a.4cdn.org/${board}/thread/${tid}.json`;
console.log(`[EXTERNAL] Fetching 4chan thread: ${url}`);
const data = await fetchWithProxy(url);
const posts = data.posts || [];
// Check which media URLs are already rehosted on this platform
const rehosts = {};
const mediaPosts = posts.filter(p => p.tim && p.ext);
const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
if (cdn4Urls.length > 0) {
try {
const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`;
rows.forEach(r => { rehosts[r.src] = r.id; });
} catch (e) {
console.error('[EXTERNAL] DB src check error:', e.message);
}
}
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
body: JSON.stringify({ success: true, posts, board, tid, rehosts })
});
} catch (err) {
console.error('[EXTERNAL] 4chan fetch error:', err.message);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'fetch_failed' })
});
}
});
// 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) => {
const ids = (req.post?.ids || '').split(',').map(Number).filter(n => n > 0);
if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: '{}' });
try {
const ratingTagIds = [1, 2, cfg.nsfl_tag_id || 3];
const rows = await db`
SELECT i.id, i.username, i.stamp,
COALESCE(uo.display_name, i.username) as display_name,
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
FROM items i
LEFT JOIN "user" u ON u."user" = i.username
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE i.id = ANY(${ids}::int[])`;
const meta = {};
rows.forEach(r => {
let rating_label = '?', rating_class = 'untagged';
if (r.rating_tag_id == 1) { rating_label = 'SFW'; rating_class = 'sfw'; }
else if (r.rating_tag_id == 2) { rating_label = 'NSFW'; rating_class = 'nsfw'; }
else if (r.rating_tag_id == (cfg.nsfl_tag_id || 3)) { rating_label = 'NSFL'; rating_class = 'nsfl'; }
meta[r.id] = {
username: r.username,
display_name: r.display_name,
avatar: r.avatar_file ? `/a/${r.avatar_file}` : (r.avatar ? `/t/${r.avatar}.webp` : '/a/default.png'),
stamp: r.stamp,
rating_class,
rating_label
};
});
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta)
});
} catch (e) {
console.error('[EXTERNAL] rehost-meta error:', e.message);
return res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: '{}' });
}
});
// 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) => {
const { board } = req.params || {};
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
try {
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
const threads = [];
for (const page of pages) {
for (const t of (page.threads || [])) {
threads.push({
no: t.no,
sub: t.sub || '',
com: (t.com || '').replace(/<[^>]+>/g, '').slice(0, 120),
replies: t.replies || 0,
images: t.images || 0,
tim: t.tim,
ext: t.ext,
sticky: t.sticky || 0
});
}
}
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' },
body: JSON.stringify({ success: true, board, threads })
});
} catch (err) {
console.error('[EXTERNAL] Catalog fetch error:', err.message);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'catalog_fetch_failed' })
});
}
});
// 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) => {
const { board, postno } = req.params || {};
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
try {
// 1) Try as thread OP — if postno IS the thread, this returns 200
try {
const thread = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${postno}.json`);
if (thread && thread.posts) {
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
body: JSON.stringify({ success: true, tid: Number(postno), board })
});
}
} catch (_) { /* 404 — post is not an OP, continue searching */ }
// 2) Search catalog's last_replies for the post
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
for (const page of pages) {
for (const t of (page.threads || [])) {
// Check OP
if (t.no === Number(postno)) {
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
body: JSON.stringify({ success: true, tid: t.no, board })
});
}
// Check last_replies
if (t.last_replies) {
for (const r of t.last_replies) {
if (r.no === Number(postno)) {
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
body: JSON.stringify({ success: true, tid: t.no, board })
});
}
}
}
}
}
// Not found
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'post_not_found' })
});
} catch (err) {
console.error('[EXTERNAL] Find post error:', err.message);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'find_failed' })
});
}
});
// 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}`;
const ext = file.split('.').pop();
const mimes = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp',
'webm': 'video/webm', 'mp4': 'video/mp4'
};
const contentType = mimes[ext] || 'application/octet-stream';
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '60',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { spawn } = await import('child_process');
const curl = spawn('curl', curlArgs);
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin',
'Transfer-Encoding': 'chunked'
});
curl.stdout.pipe(res);
curl.stderr.on('data', () => {}); // suppress stderr
curl.on('error', () => { try { res.end(); } catch(_) {} });
curl.on('close', (code) => {
if (code !== 0) try { res.end(); } catch(_) {}
});
// If the client disconnects, kill curl
req.on('close', () => { try { curl.kill(); } catch(_) {} });
});
// POST /api/v2/scroller/rehost
// Downloads an external item and adds it to the platform
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
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' }) });
const board = url.match(/boards\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|| url.match(/i\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|| null;
let rating = initialRating;
if (board === 'gif') rating = 'nsfw';
else if (board === 'wsg') rating = 'sfw';
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) });
}
const session = req.session;
try {
const uuid = await queue.genuuid();
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
// Download via curl (lightweight)
const curlArgs = [
'-s', '-f', '-L', url, '-o', tmpPath,
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
'--connect-timeout', '30',
'--max-time', '300',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
await queue.spawn('curl', curlArgs);
// Detect MIME
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
const ext = cfg.mimes[mime];
if (!ext) {
throw new Error(`Unsupported file type: ${mime}`);
}
const finalTmp = path.join(cfg.paths.tmp, `${uuid}.${ext}`);
await fs.rename(tmpPath, finalTmp);
const checksum = (await queue.spawn('sha256sum', [finalTmp])).stdout.trim().split(' ')[0];
// Repost check
if (!getBypassDuplicateCheck()) {
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(finalTmp).catch(() => {});
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' })
});
}
}
const phash = await queue.generatePHash(finalTmp).catch(() => null);
// PHash duplicate check
if (phash && !getBypassDuplicateCheck()) {
const phashMatch = await queue.checkrepostphash(phash);
if (phashMatch) {
await fs.unlink(finalTmp).catch(() => {});
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' })
});
}
}
const filename = `${uuid}.${ext}`;
const isApprovalRequired = getManualApproval();
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
await fs.copyFile(finalTmp, path.join(destDir, filename));
await fs.unlink(finalTmp).catch(() => {});
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
const [{ id: itemid }] = await db`
insert into items ${db({
src: url,
dest: filename,
mime: mime,
size: (await fs.stat(path.join(destDir, filename))).size,
checksum: insertChecksum,
phash: phash,
username: session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
RETURNING id
`;
// Process thumbnail
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
} catch (err) {
console.error('[REHOST] Thumbnail error:', err);
}
// Tags
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
// Board tag in chan-style format e.g. /gif/, /wsg/
if (board) tags.push(`/${board}/`);
// Auto-tag rating based on board
if (board === 'wsg') tags.push('sfw');
else if (board === 'gif') tags.push('nsfw');
for (const tagName of tags) {
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
if (tagRow.length === 0) {
await db`insert into tags ${db({ tag: tagName }, 'tag')} on conflict do nothing`;
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
}
if (tagRow.length) {
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: session.id })} on conflict do nothing`;
}
}
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: mime,
username: session.user,
display_name: session.display_name || null,
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
is_oc: false
})})`;
} catch (err) {
console.error('[REHOST] new_item notify failed:', err);
}
}
// Push to Matrix channel (only if auto-approved)
if (!isApprovalRequired) {
try {
const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[REHOST] Matrix notification sent for item ${itemid}`);
}
}
} catch (err) {
console.error('[REHOST] Matrix notification error:', err);
}
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, item_id: itemid })
});
} catch (err) {
console.error('[REHOST] Error:', err);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: err.message })
});
}
});
return router;
};

View File

@@ -139,6 +139,7 @@ export default (router, tpl) => {
timeago: lib.timeAgo(userData.created_at),
timefull: userData.created_at
};
userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000);
if (userData.banned) {
if (!userData.ban_expires) {

View File

@@ -6,6 +6,27 @@ import { setMotd } from "../motd.mjs";
export const clients = new Set();
const activeTabs = new Map(); // sessionId -> tabId
// Broadcast the deduplicated online-user list to all connected clients
function broadcastChatPresence() {
const seen = new Set();
const users = [];
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
});
}
}
for (const client of clients) {
client.send({ type: 'global_chat_presence', data: { users } });
}
}
function pruneInactiveClients(sessionId, currentTabId) {
for (const client of clients) {
if (client.sessionId === sessionId && client.tabId !== currentTabId) {
@@ -286,26 +307,50 @@ db.listen('global_chat_topic', (payload) => {
export default (router, tpl) => {
async function getNotificationHistory(userId, page = 1, limit = 50) {
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
const offset = (page - 1) * limit;
const notifications = await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
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 = ${userId}
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT ${limit + 1}
OFFSET ${offset}
`;
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const notifications = typeFilter
? await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
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 = ${userId}
AND n.type = ANY(${typeFilter})
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT ${limit + 1}
OFFSET ${offset}
`
: await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
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 = ${userId}
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT ${limit + 1}
OFFSET ${offset}
`;
const hasMore = notifications.length > limit;
if (hasMore) notifications.pop();
@@ -348,7 +393,7 @@ export default (router, tpl) => {
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))
ORDER BY n.created_at DESC
LIMIT 20
LIMIT 1000
`;
const processed = notifications.map(n => {
@@ -437,17 +482,14 @@ export default (router, tpl) => {
// For guests, we use tabId to avoid IP-based pruning collisions (CGNAT).
const sessionId = sessionCookie || `guest-${tabId}`;
// Pruning/Active logic only for logged-in users
// sessionId used for presence deduplication only — all tabs from same session connect freely
// Soft cap: max 10 SSE connections per session (prevents runaway tab abuse)
const MAX_TABS_PER_SESSION = 10;
if (!isGuest) {
const currentActive = activeTabs.get(sessionId);
if (currentActive && currentActive !== tabId) {
// Check if the current active tab is actually still connected
const activeClient = Array.from(clients).find(c => c.sessionId === sessionId && c.tabId === currentActive);
if (activeClient) {
// console.log(`[SSE] Denying connection for inactive tab ${tabId} (Active: ${currentActive})`);
res.writeHead(204); // No Content
return res.end();
}
const sessionClients = Array.from(clients).filter(c => c.sessionId === sessionId);
if (sessionClients.length >= MAX_TABS_PER_SESSION) {
// Close the oldest connection (FIFO) to free the slot
sessionClients[0].close();
}
}
@@ -463,6 +505,11 @@ export default (router, tpl) => {
const client = {
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
username: req.session?.user || null,
display_name: req.session?.display_name || null,
avatar_file: req.session?.avatar_file || null,
avatar: req.session?.avatar || null,
username_color: req.session?.username_color || null,
sessionId,
tabId,
send: (data) => {
@@ -500,13 +547,11 @@ export default (router, tpl) => {
}
// Set as active tab and prune others (only for logged-in users)
if (!isGuest) {
activeTabs.set(sessionId, tabId);
pruneInactiveClients(sessionId, tabId);
}
// Track active tab (no pruning — all tabs are allowed to coexist)
if (!isGuest) activeTabs.set(sessionId, tabId);
clients.add(client);
broadcastChatPresence(); // notify everyone of new user
// Keep-alive ping
const pingInterval = setInterval(() => {
@@ -520,6 +565,7 @@ export default (router, tpl) => {
res.on('close', () => {
clearInterval(pingInterval);
clients.delete(client);
broadcastChatPresence(); // notify everyone user left
if (activeTabs.get(sessionId) === tabId) {
// activeTabs.delete(sessionId); // Keep it set so we know who was last active
}
@@ -531,11 +577,9 @@ export default (router, tpl) => {
const tabId = req.url.qs?.tabId;
const sessionId = req.cookies?.session;
// Only track active tabs for logged-in users
// Track which tab is focused (informational only, no pruning)
if (tabId && sessionId) {
console.log(`[SSE] Tab ${tabId} became active for session ${sessionId}`);
activeTabs.set(sessionId, tabId);
pruneInactiveClients(sessionId, tabId);
return res.reply({ body: JSON.stringify({ success: true }) });
}
@@ -546,7 +590,8 @@ export default (router, tpl) => {
// Notification History Page
router.get('/notifications', async (req, res) => {
if (!req.session) return res.redirect('/login');
const data = await getNotificationHistory(req.session.id, 1);
const tab = req.url.qs?.tab || 'user';
const data = await getNotificationHistory(req.session.id, 1, 50, tab);
data.session = req.session;
data.hidePagination = true;
data.pagination = {
@@ -564,7 +609,8 @@ export default (router, tpl) => {
success: false
}, 401);
const page = parseInt(req.url.qs.page) || 1;
const data = await getNotificationHistory(req.session.id, page);
const tab = req.url.qs.tab || null;
const data = await getNotificationHistory(req.session.id, page, 50, tab);
const html = tpl.render('snippets/notifications-list', data, req);

View File

@@ -414,7 +414,7 @@ process.on('uncaughtException', err => {
// because the session middleware will have completed by the time router callbacks execute.
app.use(async (req, res) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps'].includes(req.url.pathname)) return;
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta'].includes(req.url.pathname)) return;
// Hall manager routes are handled by bypass middleware with their own session auth
if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
// User hall image upload is handled by bypass middleware below
@@ -737,6 +737,7 @@ process.on('uncaughtException', err => {
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
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',
get fonts() {
try {