Update base
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
467
src/inc/routes/external.mjs
Normal 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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user