From a86c523d178c337db379907fff0c6fc573fb0275 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sat, 16 May 2026 16:11:22 +0700 Subject: [PATCH] fix(post form): block temporary media links --- public/translations/ar/default.json | 3 +- public/translations/bn/default.json | 3 +- public/translations/cs/default.json | 3 +- public/translations/da/default.json | 3 +- public/translations/de/default.json | 3 +- public/translations/el/default.json | 3 +- public/translations/en/default.json | 1 + public/translations/es/default.json | 3 +- public/translations/fa/default.json | 3 +- public/translations/fi/default.json | 3 +- public/translations/fil/default.json | 3 +- public/translations/fr/default.json | 3 +- public/translations/he/default.json | 3 +- public/translations/hi/default.json | 3 +- public/translations/hu/default.json | 3 +- public/translations/id/default.json | 3 +- public/translations/it/default.json | 3 +- public/translations/ja/default.json | 3 +- public/translations/ko/default.json | 3 +- public/translations/mr/default.json | 3 +- public/translations/nl/default.json | 3 +- public/translations/no/default.json | 3 +- public/translations/pl/default.json | 3 +- public/translations/pt/default.json | 3 +- public/translations/ro/default.json | 3 +- public/translations/ru/default.json | 3 +- public/translations/sq/default.json | 3 +- public/translations/sv/default.json | 3 +- public/translations/te/default.json | 3 +- public/translations/th/default.json | 3 +- public/translations/tr/default.json | 3 +- public/translations/uk/default.json | 3 +- public/translations/ur/default.json | 3 +- public/translations/vi/default.json | 3 +- public/translations/zh/default.json | 3 +- .../post-form/__tests__/post-form.test.tsx | 10 ++++-- src/components/post-form/post-form.module.css | 5 +++ src/components/post-form/post-form.tsx | 19 ++++++++-- .../__tests__/reply-modal.test.tsx | 6 ++++ src/components/reply-modal/reply-modal.tsx | 6 ++++ src/lib/utils/__tests__/url-utils.test.ts | 10 ++++++ src/lib/utils/media-link-validation-utils.ts | 8 +++++ src/lib/utils/url-utils.ts | 35 +++++++++++++++++++ 43 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 src/lib/utils/media-link-validation-utils.ts diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 9eaac0d0..22ba4d3e 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "مستضافة خارج 5chan", "media_failed_to_load_open_source": "افتح الملف الأصلي", "media_failed_to_load_inline": "فشلت الصورة الخارجية من {{host}}. جرّب VPN.", - "media_failed_to_load_inline_unknown": "فشلت الصورة الخارجية. جرّب VPN." + "media_failed_to_load_inline_unknown": "فشلت الصورة الخارجية. جرّب VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index 51c91c40..1950c2de 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "5chan-এর বাইরে হোস্ট করা", "media_failed_to_load_open_source": "মূল ফাইল খুলুন", "media_failed_to_load_inline": "{{host}} থেকে বাইরের ছবি লোড হয়নি। VPN চেষ্টা করুন।", - "media_failed_to_load_inline_unknown": "বাইরের ছবি লোড হয়নি। VPN চেষ্টা করুন।" + "media_failed_to_load_inline_unknown": "বাইরের ছবি লোড হয়নি। VPN চেষ্টা করুন।", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 4da29743..40ed5aba 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Hostováno mimo 5chan", "media_failed_to_load_open_source": "Otevřít původní soubor", "media_failed_to_load_inline": "Externí obrázek z {{host}} selhal. Zkuste VPN.", - "media_failed_to_load_inline_unknown": "Externí obrázek selhal. Zkuste VPN." + "media_failed_to_load_inline_unknown": "Externí obrázek selhal. Zkuste VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 337e9ba0..ed6e2e43 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Hostet uden for 5chan", "media_failed_to_load_open_source": "Åbn originalfil", "media_failed_to_load_inline": "Eksternt billede fra {{host}} fejlede. Prøv VPN.", - "media_failed_to_load_inline_unknown": "Eksternt billede fejlede. Prøv VPN." + "media_failed_to_load_inline_unknown": "Eksternt billede fejlede. Prøv VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 69ec4483..e8448503 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Nicht auf 5chan gehostet", "media_failed_to_load_open_source": "Originaldatei öffnen", "media_failed_to_load_inline": "Externes Bild von {{host}} fehlgeschlagen. VPN versuchen.", - "media_failed_to_load_inline_unknown": "Externes Bild fehlgeschlagen. VPN versuchen." + "media_failed_to_load_inline_unknown": "Externes Bild fehlgeschlagen. VPN versuchen.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 6ee31fb6..8fe4deb2 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Φιλοξενείται εκτός 5chan", "media_failed_to_load_open_source": "Άνοιγμα αρχικού αρχείου", "media_failed_to_load_inline": "Η εξωτερική εικόνα από {{host}} απέτυχε. Δοκιμάστε VPN.", - "media_failed_to_load_inline_unknown": "Η εξωτερική εικόνα απέτυχε. Δοκιμάστε VPN." + "media_failed_to_load_inline_unknown": "Η εξωτερική εικόνα απέτυχε. Δοκιμάστε VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 925c2f97..11edde70 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -187,6 +187,7 @@ "filtered_threads": "Filtered threads", "no_board_selected_warning": "Please select a board before posting.", "invalid_url_alert": "The provided link is not a valid URL.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead.", "empty_comment_alert": "Cannot post empty comment.", "stored_locally": "Stored locally (in {{location}} data), not synced across devices.", "choose_one": "Choose one:", diff --git a/public/translations/es/default.json b/public/translations/es/default.json index c90fc63f..5aa82ec7 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Host externo, no 5chan", "media_failed_to_load_open_source": "Abrir archivo original", "media_failed_to_load_inline": "Falló la imagen externa de {{host}}. Prueba una VPN.", - "media_failed_to_load_inline_unknown": "Falló la imagen externa. Prueba una VPN." + "media_failed_to_load_inline_unknown": "Falló la imagen externa. Prueba una VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index da1399ee..57b7a108 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "میزبان خارجی، نه 5chan", "media_failed_to_load_open_source": "باز کردن فایل اصلی", "media_failed_to_load_inline": "تصویر خارجی از {{host}} بارگیری نشد. VPN را امتحان کنید.", - "media_failed_to_load_inline_unknown": "تصویر خارجی بارگیری نشد. VPN را امتحان کنید." + "media_failed_to_load_inline_unknown": "تصویر خارجی بارگیری نشد. VPN را امتحان کنید.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index de6fd557..5c9bfb0b 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Ulkoinen host, ei 5chan", "media_failed_to_load_open_source": "Avaa alkuperäinen tiedosto", "media_failed_to_load_inline": "Ulkoinen kuva sivustolta {{host}} epäonnistui. Kokeile VPN:ää.", - "media_failed_to_load_inline_unknown": "Ulkoinen kuva epäonnistui. Kokeile VPN:ää." + "media_failed_to_load_inline_unknown": "Ulkoinen kuva epäonnistui. Kokeile VPN:ää.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 550ef0eb..40650618 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Panlabas na host, hindi 5chan", "media_failed_to_load_open_source": "Buksan ang orihinal na file", "media_failed_to_load_inline": "Nabigo ang panlabas na larawan mula sa {{host}}. Subukan ang VPN.", - "media_failed_to_load_inline_unknown": "Nabigo ang panlabas na larawan. Subukan ang VPN." + "media_failed_to_load_inline_unknown": "Nabigo ang panlabas na larawan. Subukan ang VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index a8090485..3e108196 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Hôte externe, pas 5chan", "media_failed_to_load_open_source": "Ouvrir le fichier original", "media_failed_to_load_inline": "L'image externe de {{host}} a échoué. Essayez un VPN.", - "media_failed_to_load_inline_unknown": "L'image externe a échoué. Essayez un VPN." + "media_failed_to_load_inline_unknown": "L'image externe a échoué. Essayez un VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index b55d881c..ea6c62a0 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "מארח חיצוני, לא 5chan", "media_failed_to_load_open_source": "פתח את הקובץ המקורי", "media_failed_to_load_inline": "התמונה החיצונית מ-{{host}} נכשלה. נסה VPN.", - "media_failed_to_load_inline_unknown": "התמונה החיצונית נכשלה. נסה VPN." + "media_failed_to_load_inline_unknown": "התמונה החיצונית נכשלה. נסה VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index b43089d5..04ec9db1 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "बाहरी होस्ट, 5chan नहीं", "media_failed_to_load_open_source": "मूल फ़ाइल खोलें", "media_failed_to_load_inline": "{{host}} से बाहरी छवि लोड नहीं हुई। VPN आज़माएँ।", - "media_failed_to_load_inline_unknown": "बाहरी छवि लोड नहीं हुई। VPN आज़माएँ।" + "media_failed_to_load_inline_unknown": "बाहरी छवि लोड नहीं हुई। VPN आज़माएँ।", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index aee82482..e4ca4ac4 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Külső host, nem 5chan", "media_failed_to_load_open_source": "Eredeti fájl megnyitása", "media_failed_to_load_inline": "A külső kép innen nem töltődött be: {{host}}. Próbálj VPN-t.", - "media_failed_to_load_inline_unknown": "A külső kép nem töltődött be. Próbálj VPN-t." + "media_failed_to_load_inline_unknown": "A külső kép nem töltődött be. Próbálj VPN-t.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 2310b829..40889508 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Host eksternal, bukan 5chan", "media_failed_to_load_open_source": "Buka file asli", "media_failed_to_load_inline": "Gambar eksternal dari {{host}} gagal. Coba VPN.", - "media_failed_to_load_inline_unknown": "Gambar eksternal gagal. Coba VPN." + "media_failed_to_load_inline_unknown": "Gambar eksternal gagal. Coba VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index ae96fb26..dae8e787 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Host esterno, non 5chan", "media_failed_to_load_open_source": "Apri file originale", "media_failed_to_load_inline": "Immagine esterna da {{host}} non caricata. Prova una VPN.", - "media_failed_to_load_inline_unknown": "Immagine esterna non caricata. Prova una VPN." + "media_failed_to_load_inline_unknown": "Immagine esterna non caricata. Prova una VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 4f4d838c..5dd39360 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "外部ホスト、5chan ではありません", "media_failed_to_load_open_source": "元のファイルを開く", "media_failed_to_load_inline": "{{host}} の外部画像を読み込めませんでした。VPN を試してください。", - "media_failed_to_load_inline_unknown": "外部画像を読み込めませんでした。VPN を試してください。" + "media_failed_to_load_inline_unknown": "外部画像を読み込めませんでした。VPN を試してください。", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 8daabc8a..3c329d64 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "외부 호스트, 5chan 아님", "media_failed_to_load_open_source": "원본 파일 열기", "media_failed_to_load_inline": "{{host}}의 외부 이미지를 불러오지 못했습니다. VPN을 시도하세요.", - "media_failed_to_load_inline_unknown": "외부 이미지를 불러오지 못했습니다. VPN을 시도하세요." + "media_failed_to_load_inline_unknown": "외부 이미지를 불러오지 못했습니다. VPN을 시도하세요.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 3ddfc741..4da962e6 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "बाह्य होस्ट, 5chan नाही", "media_failed_to_load_open_source": "मूळ फाइल उघडा", "media_failed_to_load_inline": "{{host}} वरील बाह्य प्रतिमा लोड झाली नाही. VPN वापरून पाहा.", - "media_failed_to_load_inline_unknown": "बाह्य प्रतिमा लोड झाली नाही. VPN वापरून पाहा." + "media_failed_to_load_inline_unknown": "बाह्य प्रतिमा लोड झाली नाही. VPN वापरून पाहा.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index ab8eb2c8..7870fbda 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Externe host, niet 5chan", "media_failed_to_load_open_source": "Origineel bestand openen", "media_failed_to_load_inline": "Externe afbeelding van {{host}} mislukt. Probeer VPN.", - "media_failed_to_load_inline_unknown": "Externe afbeelding mislukt. Probeer VPN." + "media_failed_to_load_inline_unknown": "Externe afbeelding mislukt. Probeer VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index b9b8a45a..2d3eeb51 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Ekstern host, ikke 5chan", "media_failed_to_load_open_source": "Åpne originalfil", "media_failed_to_load_inline": "Eksternt bilde fra {{host}} kunne ikke lastes. Prøv VPN.", - "media_failed_to_load_inline_unknown": "Eksternt bilde kunne ikke lastes. Prøv VPN." + "media_failed_to_load_inline_unknown": "Eksternt bilde kunne ikke lastes. Prøv VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index f3d2a64a..918a7f49 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Zewnętrzny host, nie 5chan", "media_failed_to_load_open_source": "Otwórz oryginalny plik", "media_failed_to_load_inline": "Obraz zewnętrzny z {{host}} nie załadował się. Spróbuj VPN.", - "media_failed_to_load_inline_unknown": "Obraz zewnętrzny nie załadował się. Spróbuj VPN." + "media_failed_to_load_inline_unknown": "Obraz zewnętrzny nie załadował się. Spróbuj VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index ad72f97f..d64a8e99 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Host externo, não 5chan", "media_failed_to_load_open_source": "Abrir arquivo original", "media_failed_to_load_inline": "Falha na imagem externa de {{host}}. Tente uma VPN.", - "media_failed_to_load_inline_unknown": "Falha na imagem externa. Tente uma VPN." + "media_failed_to_load_inline_unknown": "Falha na imagem externa. Tente uma VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 212bc46b..792c7376 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Host extern, nu 5chan", "media_failed_to_load_open_source": "Deschide fișierul original", "media_failed_to_load_inline": "Imaginea externă de la {{host}} a eșuat. Încearcă VPN.", - "media_failed_to_load_inline_unknown": "Imaginea externă a eșuat. Încearcă VPN." + "media_failed_to_load_inline_unknown": "Imaginea externă a eșuat. Încearcă VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index c346d867..680810cd 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Внешний хост, не 5chan", "media_failed_to_load_open_source": "Открыть исходный файл", "media_failed_to_load_inline": "Внешнее изображение с {{host}} не загрузилось. Попробуйте VPN.", - "media_failed_to_load_inline_unknown": "Внешнее изображение не загрузилось. Попробуйте VPN." + "media_failed_to_load_inline_unknown": "Внешнее изображение не загрузилось. Попробуйте VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 2db7c8d8..2a53216c 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Host i jashtëm, jo 5chan", "media_failed_to_load_open_source": "Hap skedarin origjinal", "media_failed_to_load_inline": "Imazhi i jashtëm nga {{host}} dështoi. Provoni VPN.", - "media_failed_to_load_inline_unknown": "Imazhi i jashtëm dështoi. Provoni VPN." + "media_failed_to_load_inline_unknown": "Imazhi i jashtëm dështoi. Provoni VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 628164a6..92b78829 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Extern host, inte 5chan", "media_failed_to_load_open_source": "Öppna originalfil", "media_failed_to_load_inline": "Extern bild från {{host}} kunde inte läsas in. Prova VPN.", - "media_failed_to_load_inline_unknown": "Extern bild kunde inte läsas in. Prova VPN." + "media_failed_to_load_inline_unknown": "Extern bild kunde inte läsas in. Prova VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index bf9f51f4..161b3948 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "బాహ్య హోస్ట్, 5chan కాదు", "media_failed_to_load_open_source": "అసలు ఫైల్‌ను తెరవండి", "media_failed_to_load_inline": "{{host}} నుండి బాహ్య చిత్రం లోడ్ కాలేదు. VPN ప్రయత్నించండి.", - "media_failed_to_load_inline_unknown": "బాహ్య చిత్రం లోడ్ కాలేదు. VPN ప్రయత్నించండి." + "media_failed_to_load_inline_unknown": "బాహ్య చిత్రం లోడ్ కాలేదు. VPN ప్రయత్నించండి.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 3a39085e..0c0889fd 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "โฮสต์ภายนอก ไม่ใช่ 5chan", "media_failed_to_load_open_source": "เปิดไฟล์ต้นฉบับ", "media_failed_to_load_inline": "โหลดรูปภาพภายนอกจาก {{host}} ไม่สำเร็จ ลอง VPN", - "media_failed_to_load_inline_unknown": "โหลดรูปภาพภายนอกไม่สำเร็จ ลอง VPN" + "media_failed_to_load_inline_unknown": "โหลดรูปภาพภายนอกไม่สำเร็จ ลอง VPN", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index b0a8972b..b91d681e 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Harici host, 5chan değil", "media_failed_to_load_open_source": "Orijinal dosyayı aç", "media_failed_to_load_inline": "{{host}} harici görseli yüklenemedi. VPN deneyin.", - "media_failed_to_load_inline_unknown": "Harici görsel yüklenemedi. VPN deneyin." + "media_failed_to_load_inline_unknown": "Harici görsel yüklenemedi. VPN deneyin.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 65e5e8fb..4df79591 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Зовнішній хост, не 5chan", "media_failed_to_load_open_source": "Відкрити оригінальний файл", "media_failed_to_load_inline": "Зовнішнє зображення з {{host}} не завантажилося. Спробуйте VPN.", - "media_failed_to_load_inline_unknown": "Зовнішнє зображення не завантажилося. Спробуйте VPN." + "media_failed_to_load_inline_unknown": "Зовнішнє зображення не завантажилося. Спробуйте VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index c4da8d8b..a8725937 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "بیرونی ہوسٹ، 5chan نہیں", "media_failed_to_load_open_source": "اصل فائل کھولیں", "media_failed_to_load_inline": "{{host}} سے بیرونی تصویر لوڈ نہیں ہوئی۔ VPN آزمائیں۔", - "media_failed_to_load_inline_unknown": "بیرونی تصویر لوڈ نہیں ہوئی۔ VPN آزمائیں۔" + "media_failed_to_load_inline_unknown": "بیرونی تصویر لوڈ نہیں ہوئی۔ VPN آزمائیں۔", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 36360be5..017fb8ce 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "Máy chủ ngoài, không phải 5chan", "media_failed_to_load_open_source": "Mở tệp gốc", "media_failed_to_load_inline": "Không tải được ảnh bên ngoài từ {{host}}. Thử VPN.", - "media_failed_to_load_inline_unknown": "Không tải được ảnh bên ngoài. Thử VPN." + "media_failed_to_load_inline_unknown": "Không tải được ảnh bên ngoài. Thử VPN.", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 28899612..fa7591e3 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -387,5 +387,6 @@ "media_failed_to_load_source_unknown": "外部主机,不是 5chan", "media_failed_to_load_open_source": "打开原始文件", "media_failed_to_load_inline": "来自 {{host}} 的外部图片加载失败。试试 VPN。", - "media_failed_to_load_inline_unknown": "外部图片加载失败。试试 VPN。" + "media_failed_to_load_inline_unknown": "外部图片加载失败。试试 VPN。", + "expiring_media_link_alert": "Media from {{domain}} expires quickly. Use a stable media link instead." } diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index e62442b8..68d46e1a 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -52,7 +52,7 @@ const testState = vi.hoisted(() => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => (options?.domain ? `${key}:${options.domain}` : key), }), })); @@ -401,8 +401,14 @@ describe('PostForm', () => { expect(globalThis.alert).toHaveBeenCalledWith('invalid_url_alert'); (globalThis.alert as ReturnType).mockClear(); - await dispatchInput(linkInput as HTMLInputElement, ''); await dispatchInput(textarea as HTMLTextAreaElement, 'A valid body'); + await dispatchInput(linkInput as HTMLInputElement, 'https://i.4cdn.org/gif/file.jpg'); + await clickByText(table as HTMLTableElement, 'post'); + expect(globalThis.alert).not.toHaveBeenCalled(); + expect(container.textContent).toContain('error: expiring_media_link_alert:i.4cdn.org'); + expect(testState.publishPostMock).not.toHaveBeenCalled(); + + await dispatchInput(linkInput as HTMLInputElement, ''); await clickByText(table as HTMLTableElement, 'post'); expect(globalThis.alert).toHaveBeenCalledWith('no_board_selected_warning'); diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index eba42cff..e9563781 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -188,6 +188,11 @@ color: red; } +.formError { + margin: 8px 0; + padding: 6px 5px; +} + .status { padding: 5px; } diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 10a7ed3c..7b900a21 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -6,6 +6,7 @@ import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bi import getShortAddress from '../../lib/get-short-address'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils'; +import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; @@ -346,6 +347,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const accountCommunityAddresses = useAccountCommunityAddresses(); const [lengthError, setLengthError] = useState(null); + const [formError, setFormError] = useState(null); const checkContentLength = useRef( debounce((content: string, t: TFunction) => { @@ -377,6 +379,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: checkContentLength.cancel(); setLengthError(null); + setFormError(null); if (!currentTitle && !currentContent && !currentUrl) { alert(t('empty_comment_alert')); @@ -386,6 +389,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: alert(t('invalid_url_alert')); return; } + const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null; + if (expiringMediaLinkAlert) { + setFormError(expiringMediaLinkAlert); + return; + } if (currentContent.length > 2000) { alert(t('error') + ': ' + t('field_too_long')); @@ -432,6 +440,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: checkContentLength.cancel(); setLengthError(null); + setFormError(null); if (!currentContent && !currentUrl) { alert(t('empty_comment_alert')); @@ -442,6 +451,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: alert(t('invalid_url_alert')); return; } + const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null; + if (expiringMediaLinkAlert) { + setFormError(expiringMediaLinkAlert); + return; + } if (currentContent.length > 2000) { alert(t('error') + ': ' + t('field_too_long')); @@ -527,8 +541,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: /> - {publishPostError &&
{publishPostError}
} - {publishReplyError &&
{publishReplyError}
} + {formError &&
{formError}
} + {publishPostError &&
{publishPostError}
} + {publishReplyError &&
{publishReplyError}
} {publishReplyStateMessage &&
{publishReplyStateMessage}
} ); diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index cde760af..43d4be94 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -414,6 +414,12 @@ describe('ReplyModal', () => { expect(container.textContent).toContain('error: invalid_url_alert'); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ spoiler: true }); + await dispatchInput(linkInput, 'https://temp.sh/example.png'); + await clickButtonByText('post'); + + expect(container.textContent).toContain('error: expiring_media_link_alert:{"domain":"temp.sh"}'); + expect(testState.publishReplyMock).not.toHaveBeenCalled(); + await dispatchInput(linkInput, 'https://example.com/file.png'); await clickButtonByText('post'); diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 4e9217f5..267782f6 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -3,6 +3,7 @@ import { useLocation, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils'; import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useSelectedTextStore from '../../stores/use-selected-text-store'; @@ -103,6 +104,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa setError(t('error') + ': ' + t('invalid_url_alert')); return; } + const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null; + if (expiringMediaLinkAlert) { + setError(expiringMediaLinkAlert); + return; + } checkContentLengthRef.current.cancel(); setLengthError(null); diff --git a/src/lib/utils/__tests__/url-utils.test.ts b/src/lib/utils/__tests__/url-utils.test.ts index 7c62e6da..9c86ebb0 100644 --- a/src/lib/utils/__tests__/url-utils.test.ts +++ b/src/lib/utils/__tests__/url-utils.test.ts @@ -10,6 +10,7 @@ vi.mock('../clipboard-utils', () => ({ import { copyShareLinkToClipboard, + getExpiringMediaLinkHostname, getHostname, getPublishURLFilename, is5chanLink, @@ -62,6 +63,15 @@ describe('url-utils', () => { expect(getPublishURLFilename('not-a-url')).toBeNull(); }); + it('detects publish media hosts with temporary links', () => { + expect(getExpiringMediaLinkHostname('https://i.4cdn.org/gif/1712345678900.jpg')).toBe('i.4cdn.org'); + expect(getExpiringMediaLinkHostname('http://litterbox.catbox.moe/u/example.png')).toBe('litterbox.catbox.moe'); + expect(getExpiringMediaLinkHostname('https://www.tmpfiles.org/dl/123/file.mp4')).toBe('tmpfiles.org'); + expect(getExpiringMediaLinkHostname('https://cdn.file.kiwi/example')).toBe('file.kiwi'); + expect(getExpiringMediaLinkHostname('https://example.com/file.png')).toBeNull(); + expect(getExpiringMediaLinkHostname('not-a-url')).toBeNull(); + }); + it('copies share links for threads and catalog pages using the production fallback base url', async () => { await copyShareLinkToClipboard('music.eth', 'thread', 'cid-123'); expect(testState.copyToClipboardMock).toHaveBeenCalledWith('https://5chan.app/#/music.eth/thread/cid-123'); diff --git a/src/lib/utils/media-link-validation-utils.ts b/src/lib/utils/media-link-validation-utils.ts new file mode 100644 index 00000000..b0c94f58 --- /dev/null +++ b/src/lib/utils/media-link-validation-utils.ts @@ -0,0 +1,8 @@ +import { getExpiringMediaLinkHostname } from './url-utils'; + +type TranslateFn = (key: string, options?: Record) => string; + +export const getExpiringMediaLinkAlert = (url: string, t: TranslateFn): string | null => { + const expiringMediaLinkHostname = getExpiringMediaLinkHostname(url); + return expiringMediaLinkHostname ? `${t('error')}: ${t('expiring_media_link_alert', { domain: expiringMediaLinkHostname })}` : null; +}; diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index 6b514484..4628a1f2 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -67,6 +67,41 @@ export const normalizePublishURL = (url: string) => { return trimmedUrl; }; +const EXPIRING_MEDIA_LINK_HOSTNAMES = [ + // 4chan CDN media disappears when threads are pruned, usually after a few hours or days. + 'i.4cdn.org', + // Litterbox temporary uploads can expire after 1 hour, 12 hours, 1 day, or 3 days. + 'litterbox.catbox.moe', + // tmpfiles.org uploads expire after 60 minutes, 6 hours, 12 hours, or 24 hours. + 'tmpfiles.org', + // Filebin uploads are deleted automatically after about 6 days. + 'filebin.net', + // temp.sh files expire after 3 days. + 'temp.sh', + // Termbin pastes are automatically deleted after 1 week. + 'termbin.com', + // Uguu files expire after 3 hours. + 'uguu.se', + // file.kiwi encrypted files are deleted after about 4 days by default. + 'file.kiwi', +] as const; + +const normalizeHostnameForMatching = (hostname: string) => hostname.toLowerCase().replace(/^www\./, ''); + +export const getExpiringMediaLinkHostname = (url: string): string | null => { + try { + const parsedUrl = new URL(normalizePublishURL(url)); + if (parsedUrl.protocol !== 'https:') { + return null; + } + + const hostname = normalizeHostnameForMatching(parsedUrl.hostname); + return EXPIRING_MEDIA_LINK_HOSTNAMES.find((expiringHostname) => hostname === expiringHostname || hostname.endsWith(`.${expiringHostname}`)) || null; + } catch { + return null; + } +}; + export const isValidPublishURL = (url: string) => { try { return new URL(normalizePublishURL(url)).protocol === 'https:';