From 8db637cb39ec4c322983a9a680efdbd8f3f3465c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?=
Date: Sun, 8 Jun 2025 20:47:57 -0700
Subject: [PATCH] feat(ui): add user setting to control `target="_blank"` on
links
Rationale: Opening links in the current tab is the default browser behavior.
Using `target="_blank"` on external links can lead to accessibility issues and override user preferences. It may also interfere with assistive technologies and expected browser behavior.
To maintain backward compatibility, this option is enabled by default (`true`), which adds `target="_blank"` to links.
---
client/model.go | 118 +++++-----
internal/database/migrations.go | 4 +
internal/locale/translations/de_DE.json | 1 +
internal/locale/translations/el_EL.json | 1 +
internal/locale/translations/en_US.json | 1 +
internal/locale/translations/es_ES.json | 1 +
internal/locale/translations/fi_FI.json | 1 +
internal/locale/translations/fr_FR.json | 1 +
internal/locale/translations/hi_IN.json | 1 +
internal/locale/translations/id_ID.json | 1 +
internal/locale/translations/it_IT.json | 1 +
internal/locale/translations/ja_JP.json | 1 +
.../locale/translations/nan_Latn_pehoeji.json | 1 +
internal/locale/translations/nl_NL.json | 1 +
internal/locale/translations/pl_PL.json | 1 +
internal/locale/translations/pt_BR.json | 1 +
internal/locale/translations/ro_RO.json | 1 +
internal/locale/translations/ru_RU.json | 1 +
internal/locale/translations/tr_TR.json | 1 +
internal/locale/translations/uk_UA.json | 1 +
internal/locale/translations/zh_CN.json | 1 +
internal/locale/translations/zh_TW.json | 1 +
internal/model/user.go | 6 +
internal/reader/processor/processor.go | 4 +-
internal/reader/sanitizer/sanitizer.go | 33 ++-
internal/reader/sanitizer/sanitizer_test.go | 210 ++++++++++--------
internal/storage/user.go | 33 ++-
.../template/templates/common/feed_list.html | 2 +-
.../template/templates/common/item_meta.html | 6 +-
.../templates/views/add_subscription.html | 10 +-
.../templates/views/choose_subscription.html | 2 +-
.../template/templates/views/edit_feed.html | 14 +-
internal/template/templates/views/entry.html | 10 +-
.../templates/views/feed_entries.html | 2 +-
.../templates/views/integrations.html | 4 +-
.../template/templates/views/settings.html | 8 +-
.../templates/views/shared_entries.html | 2 +-
internal/ui/form/settings.go | 67 +++---
internal/ui/settings_show.go | 49 ++--
39 files changed, 345 insertions(+), 259 deletions(-)
diff --git a/client/model.go b/client/model.go
index 1e064d26..1f7a28ea 100644
--- a/client/model.go
+++ b/client/model.go
@@ -17,36 +17,37 @@ const (
// User represents a user in the system.
type User struct {
- ID int64 `json:"id"`
- Username string `json:"username"`
- Password string `json:"password,omitempty"`
- IsAdmin bool `json:"is_admin"`
- Theme string `json:"theme"`
- Language string `json:"language"`
- Timezone string `json:"timezone"`
- EntryDirection string `json:"entry_sorting_direction"`
- EntryOrder string `json:"entry_sorting_order"`
- Stylesheet string `json:"stylesheet"`
- CustomJS string `json:"custom_js"`
- GoogleID string `json:"google_id"`
- OpenIDConnectID string `json:"openid_connect_id"`
- EntriesPerPage int `json:"entries_per_page"`
- KeyboardShortcuts bool `json:"keyboard_shortcuts"`
- ShowReadingTime bool `json:"show_reading_time"`
- EntrySwipe bool `json:"entry_swipe"`
- GestureNav string `json:"gesture_nav"`
- LastLoginAt *time.Time `json:"last_login_at"`
- DisplayMode string `json:"display_mode"`
- DefaultReadingSpeed int `json:"default_reading_speed"`
- CJKReadingSpeed int `json:"cjk_reading_speed"`
- DefaultHomePage string `json:"default_home_page"`
- CategoriesSortingOrder string `json:"categories_sorting_order"`
- MarkReadOnView bool `json:"mark_read_on_view"`
- MediaPlaybackRate float64 `json:"media_playback_rate"`
- BlockFilterEntryRules string `json:"block_filter_entry_rules"`
- KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
- ExternalFontHosts string `json:"external_font_hosts"`
- AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ Password string `json:"password,omitempty"`
+ IsAdmin bool `json:"is_admin"`
+ Theme string `json:"theme"`
+ Language string `json:"language"`
+ Timezone string `json:"timezone"`
+ EntryDirection string `json:"entry_sorting_direction"`
+ EntryOrder string `json:"entry_sorting_order"`
+ Stylesheet string `json:"stylesheet"`
+ CustomJS string `json:"custom_js"`
+ GoogleID string `json:"google_id"`
+ OpenIDConnectID string `json:"openid_connect_id"`
+ EntriesPerPage int `json:"entries_per_page"`
+ KeyboardShortcuts bool `json:"keyboard_shortcuts"`
+ ShowReadingTime bool `json:"show_reading_time"`
+ EntrySwipe bool `json:"entry_swipe"`
+ GestureNav string `json:"gesture_nav"`
+ LastLoginAt *time.Time `json:"last_login_at"`
+ DisplayMode string `json:"display_mode"`
+ DefaultReadingSpeed int `json:"default_reading_speed"`
+ CJKReadingSpeed int `json:"cjk_reading_speed"`
+ DefaultHomePage string `json:"default_home_page"`
+ CategoriesSortingOrder string `json:"categories_sorting_order"`
+ MarkReadOnView bool `json:"mark_read_on_view"`
+ MediaPlaybackRate float64 `json:"media_playback_rate"`
+ BlockFilterEntryRules string `json:"block_filter_entry_rules"`
+ KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
+ ExternalFontHosts string `json:"external_font_hosts"`
+ AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
+ OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
func (u User) String() string {
@@ -64,34 +65,35 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
- Username *string `json:"username"`
- Password *string `json:"password"`
- IsAdmin *bool `json:"is_admin"`
- Theme *string `json:"theme"`
- Language *string `json:"language"`
- Timezone *string `json:"timezone"`
- EntryDirection *string `json:"entry_sorting_direction"`
- EntryOrder *string `json:"entry_sorting_order"`
- Stylesheet *string `json:"stylesheet"`
- CustomJS *string `json:"custom_js"`
- GoogleID *string `json:"google_id"`
- OpenIDConnectID *string `json:"openid_connect_id"`
- EntriesPerPage *int `json:"entries_per_page"`
- KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
- ShowReadingTime *bool `json:"show_reading_time"`
- EntrySwipe *bool `json:"entry_swipe"`
- GestureNav *string `json:"gesture_nav"`
- DisplayMode *string `json:"display_mode"`
- DefaultReadingSpeed *int `json:"default_reading_speed"`
- CJKReadingSpeed *int `json:"cjk_reading_speed"`
- DefaultHomePage *string `json:"default_home_page"`
- CategoriesSortingOrder *string `json:"categories_sorting_order"`
- MarkReadOnView *bool `json:"mark_read_on_view"`
- MediaPlaybackRate *float64 `json:"media_playback_rate"`
- BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
- KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
- ExternalFontHosts *string `json:"external_font_hosts"`
- AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
+ Username *string `json:"username"`
+ Password *string `json:"password"`
+ IsAdmin *bool `json:"is_admin"`
+ Theme *string `json:"theme"`
+ Language *string `json:"language"`
+ Timezone *string `json:"timezone"`
+ EntryDirection *string `json:"entry_sorting_direction"`
+ EntryOrder *string `json:"entry_sorting_order"`
+ Stylesheet *string `json:"stylesheet"`
+ CustomJS *string `json:"custom_js"`
+ GoogleID *string `json:"google_id"`
+ OpenIDConnectID *string `json:"openid_connect_id"`
+ EntriesPerPage *int `json:"entries_per_page"`
+ KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
+ ShowReadingTime *bool `json:"show_reading_time"`
+ EntrySwipe *bool `json:"entry_swipe"`
+ GestureNav *string `json:"gesture_nav"`
+ DisplayMode *string `json:"display_mode"`
+ DefaultReadingSpeed *int `json:"default_reading_speed"`
+ CJKReadingSpeed *int `json:"cjk_reading_speed"`
+ DefaultHomePage *string `json:"default_home_page"`
+ CategoriesSortingOrder *string `json:"categories_sorting_order"`
+ MarkReadOnView *bool `json:"mark_read_on_view"`
+ MediaPlaybackRate *float64 `json:"media_playback_rate"`
+ BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
+ KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
+ ExternalFontHosts *string `json:"external_font_hosts"`
+ AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
+ OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Users represents a list of users.
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 70c11792..ba16eb2f 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -1086,4 +1086,8 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return
},
+ func(tx *sql.Tx, _ string) (err error) {
+ _, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)
+ return err
+ },
}
diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json
index a7ecea18..d839a218 100644
--- a/internal/locale/translations/de_DE.json
+++ b/internal/locale/translations/de_DE.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
+ "form.prefs.label.open_external_links_in_new_tab": "Externe Links in einem neuen Tab öffnen (fügt target=\"_blank\" zu Links hinzu)",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Zeitzone",
diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json
index bb672e5e..5b6bff6b 100644
--- a/internal/locale/translations/el_EL.json
+++ b/internal/locale/translations/el_EL.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή",
"form.prefs.label.mark_read_on_view_or_media_completion": "Σήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή. Για ήχο/βίντεο, σήμανση ως αναγνωσμένου στο 90%% ολοκλήρωσης",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
+ "form.prefs.label.open_external_links_in_new_tab": "Άνοιγμα εξωτερικών συνδέσμων σε νέα καρτέλα (προσθέτει target=\"_blank\" στους συνδέσμους)",
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
"form.prefs.label.theme": "Θέμα",
"form.prefs.label.timezone": "Ζώνη Ώρας",
diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json
index 8df8b741..1961ef7d 100644
--- a/internal/locale/translations/en_US.json
+++ b/internal/locale/translations/en_US.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
+ "form.prefs.label.open_external_links_in_new_tab": "Open external links in a new tab (adds target=\"_blank\" to links)",
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
"form.prefs.label.theme": "Theme",
"form.prefs.label.timezone": "Timezone",
diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json
index 7c4cb163..a1fe1e3f 100644
--- a/internal/locale/translations/es_ES.json
+++ b/internal/locale/translations/es_ES.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
+ "form.prefs.label.open_external_links_in_new_tab": "Abrir enlaces externos en una nueva pestaña (agrega target=\"_blank\" a los enlaces)",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Zona horaria",
diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json
index c9ff7b8a..17517d6a 100644
--- a/internal/locale/translations/fi_FI.json
+++ b/internal/locale/translations/fi_FI.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
+ "form.prefs.label.open_external_links_in_new_tab": "Avaa ulkoiset linkit uuteen välilehteen (lisää target=\"_blank\" linkkeihin)",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
"form.prefs.label.theme": "Teema",
"form.prefs.label.timezone": "Aikavyöhyke",
diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json
index a1e25317..582f62aa 100644
--- a/internal/locale/translations/fr_FR.json
+++ b/internal/locale/translations/fr_FR.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
+ "form.prefs.label.open_external_links_in_new_tab": "Ouvrir les liens externes dans un nouvel onglet (ajoute target=\"_blank\" aux liens)",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
"form.prefs.label.theme": "Thème",
"form.prefs.label.timezone": "Fuseau horaire",
diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json
index d6b9ff13..e82a9eef 100644
--- a/internal/locale/translations/hi_IN.json
+++ b/internal/locale/translations/hi_IN.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
+ "form.prefs.label.open_external_links_in_new_tab": "बाहरी लिंक को एक नए टैब में खोलें (लिंक में target=\"_blank\" जोड़ता है)",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.theme": "थीम",
"form.prefs.label.timezone": "समय क्षेत्र",
diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json
index 6f05d919..1515b97a 100644
--- a/internal/locale/translations/id_ID.json
+++ b/internal/locale/translations/id_ID.json
@@ -353,6 +353,7 @@
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
"form.prefs.label.mark_read_on_view_or_media_completion": "Tandai entri sebagai telah dibaca ketika dilihat. Untuk audio/video, tandai sebagai telah dibaca ketika sudah 90% didengar/ditonton.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
+ "form.prefs.label.open_external_links_in_new_tab": "Buka tautan eksternal di tab baru (menambahkan target=\"_blank\" ke tautan)",
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Zona Waktu",
diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json
index ebd46064..bd20df67 100644
--- a/internal/locale/translations/it_IT.json
+++ b/internal/locale/translations/it_IT.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
+ "form.prefs.label.open_external_links_in_new_tab": "Apri i link esterni in una nuova scheda (aggiunge target=\"_blank\" ai link)",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Fuso orario",
diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json
index a868d969..8611dd89 100644
--- a/internal/locale/translations/ja_JP.json
+++ b/internal/locale/translations/ja_JP.json
@@ -353,6 +353,7 @@
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
+ "form.prefs.label.open_external_links_in_new_tab": "外部リンクを新しいタブで開く(リンクに target=\"_blank\" を追加)",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
"form.prefs.label.theme": "テーマ",
"form.prefs.label.timezone": "タイムゾーン",
diff --git a/internal/locale/translations/nan_Latn_pehoeji.json b/internal/locale/translations/nan_Latn_pehoeji.json
index ec8f25d5..a205342a 100644
--- a/internal/locale/translations/nan_Latn_pehoeji.json
+++ b/internal/locale/translations/nan_Latn_pehoeji.json
@@ -353,6 +353,7 @@
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
+ "form.prefs.label.open_external_links_in_new_tab": "Chhiau-chhē gōa-pō͘ liân-kiat sī tī sin ê ia̍h phah khui (kā liân-kiat chhē target=\"_blank\")",
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
"form.prefs.label.theme": "Chú-tôe",
"form.prefs.label.timezone": "Sî-khu",
diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json
index 902830a2..3f933043 100644
--- a/internal/locale/translations/nl_NL.json
+++ b/internal/locale/translations/nl_NL.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
+ "form.prefs.label.open_external_links_in_new_tab": "Open externe links in een nieuw tabblad (voegt target=\"_blank\" toe aan links)",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Tijdzone",
diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json
index 49e58849..e7306d90 100644
--- a/internal/locale/translations/pl_PL.json
+++ b/internal/locale/translations/pl_PL.json
@@ -359,6 +359,7 @@
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
+ "form.prefs.label.open_external_links_in_new_tab": "Otwieraj linki zewnętrzne w nowej karcie (dodaje target=\"_blank\" do linków)",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.timezone": "Strefa czasowa",
diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json
index e8d564c8..5d9039ae 100644
--- a/internal/locale/translations/pt_BR.json
+++ b/internal/locale/translations/pt_BR.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas como lidas quando visualizadas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar itens como lidos quando visualizados. Para áudio/vídeo, marcar como lido em 90%% de conclusão",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
+ "form.prefs.label.open_external_links_in_new_tab": "Abrir links externos em uma nova aba (adiciona target=\"_blank\" aos links)",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Fuso horário",
diff --git a/internal/locale/translations/ro_RO.json b/internal/locale/translations/ro_RO.json
index 509fa47b..40f581f5 100644
--- a/internal/locale/translations/ro_RO.json
+++ b/internal/locale/translations/ro_RO.json
@@ -359,6 +359,7 @@
"form.prefs.label.mark_read_on_view": "Marchează intrările ca citite la vizualizare",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marchează intrările ca citite la vizualizare. Pentru audio/video, marchează ca citit la redarea a 90%% de conținut",
"form.prefs.label.media_playback_rate": "Viteza de rulare audio/video",
+ "form.prefs.label.open_external_links_in_new_tab": "Deschide linkurile externe într-o filă nouă (adaugă target=\"_blank\" la linkuri)",
"form.prefs.label.show_reading_time": "Afișare timp estimat de citire pentru înregistrări",
"form.prefs.label.theme": "Temă",
"form.prefs.label.timezone": "Fus orar",
diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json
index 1228c58c..64f17db2 100644
--- a/internal/locale/translations/ru_RU.json
+++ b/internal/locale/translations/ru_RU.json
@@ -359,6 +359,7 @@
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре",
"form.prefs.label.mark_read_on_view_or_media_completion": "Отмечать статьи как прочитанные при просмотре. Для аудио/видео - при 90%% завершения воспроизведения",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
+ "form.prefs.label.open_external_links_in_new_tab": "Открывать внешние ссылки в новой вкладке (добавляет target=\"_blank\" к ссылкам)",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
"form.prefs.label.theme": "Тема",
"form.prefs.label.timezone": "Часовой пояс",
diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json
index c4a85611..61e1b6d2 100644
--- a/internal/locale/translations/tr_TR.json
+++ b/internal/locale/translations/tr_TR.json
@@ -356,6 +356,7 @@
"form.prefs.label.mark_read_on_view": "Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
+ "form.prefs.label.open_external_links_in_new_tab": "Harici bağlantıları yeni bir sekmede aç (bağlantılara target=\"_blank\" ekler)",
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Saat Dilimi",
diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json
index 7aafb14a..322feec2 100644
--- a/internal/locale/translations/uk_UA.json
+++ b/internal/locale/translations/uk_UA.json
@@ -359,6 +359,7 @@
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
+ "form.prefs.label.open_external_links_in_new_tab": "Відкривати зовнішні посилання у новій вкладці (додає target=\"_blank\" до посилань)",
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
"form.prefs.label.theme": "Тема",
"form.prefs.label.timezone": "Часовий пояс",
diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json
index 310ce2b8..e6ec8397 100644
--- a/internal/locale/translations/zh_CN.json
+++ b/internal/locale/translations/zh_CN.json
@@ -353,6 +353,7 @@
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成90%%时标记为已读",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
+ "form.prefs.label.open_external_links_in_new_tab": "在新标签页中打开外部链接(为链接添加 target=\"_blank\")",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
"form.prefs.label.theme": "主题",
"form.prefs.label.timezone": "时区",
diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json
index 778afece..ac207948 100644
--- a/internal/locale/translations/zh_TW.json
+++ b/internal/locale/translations/zh_TW.json
@@ -353,6 +353,7 @@
"form.prefs.label.mark_read_on_view": "檢視時自動將文章標記為已讀",
"form.prefs.label.mark_read_on_view_or_media_completion": "檢視文章即標記為已讀;若是音訊/視訊則在 90% 播放完成時標記",
"form.prefs.label.media_playback_rate": "音訊/視訊播放速度",
+ "form.prefs.label.open_external_links_in_new_tab": "在新分頁中開啟外部連結(為連結加上 target=\"_blank\")",
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.theme": "主題",
"form.prefs.label.timezone": "時區",
diff --git a/internal/model/user.go b/internal/model/user.go
index 5c1fa911..a16db615 100644
--- a/internal/model/user.go
+++ b/internal/model/user.go
@@ -42,6 +42,7 @@ type User struct {
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
+ OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
// UserCreationRequest represents the request to create a user.
@@ -84,6 +85,7 @@ type UserModificationRequest struct {
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
+ OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Patch updates the User object with the modification request.
@@ -203,6 +205,10 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.AlwaysOpenExternalLinks != nil {
user.AlwaysOpenExternalLinks = *u.AlwaysOpenExternalLinks
}
+
+ if u.OpenExternalLinksInNewTab != nil {
+ user.OpenExternalLinksInNewTab = *u.OpenExternalLinksInNewTab
+ }
}
// UseTimezone converts last login date to the given timezone.
diff --git a/internal/reader/processor/processor.go b/internal/reader/processor/processor.go
index e2099d2c..0915d33c 100644
--- a/internal/reader/processor/processor.go
+++ b/internal/reader/processor/processor.go
@@ -125,7 +125,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
}
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered out.
- entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
+ entry.Content = sanitizer.SanitizeHTML(pageBaseURL, entry.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
@@ -181,7 +181,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
}
rewrite.Rewriter(rewrittenEntryURL, entry, entry.Feed.RewriteRules)
- entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
+ entry.Content = sanitizer.SanitizeHTML(pageBaseURL, entry.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
return nil
}
diff --git a/internal/reader/sanitizer/sanitizer.go b/internal/reader/sanitizer/sanitizer.go
index 994f660e..28b9d94b 100644
--- a/internal/reader/sanitizer/sanitizer.go
+++ b/internal/reader/sanitizer/sanitizer.go
@@ -112,14 +112,23 @@ var (
}
)
-// Sanitize returns safe HTML.
-func Sanitize(baseURL, input string) string {
+type SanitizerOptions struct {
+ OpenLinksInNewTab bool
+}
+
+func SanitizeHTMLWithDefaultOptions(baseURL, rawHTML string) string {
+ return SanitizeHTML(baseURL, rawHTML, &SanitizerOptions{
+ OpenLinksInNewTab: true,
+ })
+}
+
+func SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) string {
var buffer strings.Builder
var tagStack []string
var parentTag string
var blockedStack []string
- tokenizer := html.NewTokenizer(strings.NewReader(input))
+ tokenizer := html.NewTokenizer(strings.NewReader(rawHTML))
for {
if tokenizer.Next() == html.ErrorToken {
err := tokenizer.Err()
@@ -166,7 +175,7 @@ func Sanitize(baseURL, input string) string {
}
if len(blockedStack) == 0 && isValidTag(tagName) {
- attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
+ attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr, sanitizerOptions)
if hasRequiredAttributes(tagName, attrNames) {
if len(attrNames) > 0 {
// Rewrite the start tag with allowed attributes.
@@ -194,7 +203,7 @@ func Sanitize(baseURL, input string) string {
continue
}
if len(blockedStack) == 0 && isValidTag(tagName) {
- attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
+ attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr, sanitizerOptions)
if hasRequiredAttributes(tagName, attrNames) {
if len(attrNames) > 0 {
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
@@ -207,7 +216,7 @@ func Sanitize(baseURL, input string) string {
}
}
-func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([]string, string) {
+func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) ([]string, string) {
var htmlAttrs, attrNames []string
var err error
var isImageLargerThanLayout bool
@@ -269,7 +278,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
}
if !isAnchorLink {
- extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
+ extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName, sanitizerOptions)
if len(extraAttrNames) > 0 {
attrNames = append(attrNames, extraAttrNames...)
htmlAttrs = append(htmlAttrs, extraHTMLAttributes...)
@@ -279,10 +288,16 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
return attrNames, strings.Join(htmlAttrs, " ")
}
-func getExtraAttributes(tagName string) ([]string, []string) {
+func getExtraAttributes(tagName string, sanitizerOptions *SanitizerOptions) ([]string, []string) {
switch tagName {
case "a":
- return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
+ attributeNames := []string{"rel", "referrerpolicy"}
+ htmlAttributes := []string{`rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`}
+ if sanitizerOptions.OpenLinksInNewTab {
+ attributeNames = append(attributeNames, "target")
+ htmlAttributes = append(htmlAttributes, `target="_blank"`)
+ }
+ return attributeNames, htmlAttributes
case "video", "audio":
return []string{"controls"}, []string{"controls"}
case "iframe":
diff --git a/internal/reader/sanitizer/sanitizer_test.go b/internal/reader/sanitizer/sanitizer_test.go
index 5cd49353..2e4d6424 100644
--- a/internal/reader/sanitizer/sanitizer_test.go
+++ b/internal/reader/sanitizer/sanitizer_test.go
@@ -33,7 +33,7 @@ func BenchmarkSanitize(b *testing.B) {
}
for range b.N {
for _, v := range testCases {
- Sanitize(v[0], v[1])
+ SanitizeHTMLWithDefaultOptions(v[0], v[1])
}
}
}
@@ -46,7 +46,7 @@ func FuzzSanitizer(f *testing.F) {
i++
}
- out := Sanitize("", orig)
+ out := SanitizeHTMLWithDefaultOptions("", orig)
tok = html.NewTokenizer(strings.NewReader(out))
j := 0
@@ -62,7 +62,7 @@ func FuzzSanitizer(f *testing.F) {
func TestValidInput(t *testing.T) {
input := `This is a text with an image:
.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if input != output {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
@@ -72,7 +72,7 @@ func TestValidInput(t *testing.T) {
func TestImgWithWidthAndHeightAttribute(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -82,7 +82,7 @@ func TestImgWithWidthAndHeightAttribute(t *testing.T) {
func TestImgWithWidthAndHeightAttributeLargerThanMinifluxLayout(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -92,7 +92,7 @@ func TestImgWithWidthAndHeightAttributeLargerThanMinifluxLayout(t *testing.T) {
func TestImgWithIncorrectWidthAndHeightAttribute(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -102,7 +102,7 @@ func TestImgWithIncorrectWidthAndHeightAttribute(t *testing.T) {
func TestImgWithTextDataURL(t *testing.T) {
input := `
`
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -112,7 +112,7 @@ func TestImgWithTextDataURL(t *testing.T) {
func TestImgWithDataURL(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -122,7 +122,7 @@ func TestImgWithDataURL(t *testing.T) {
func TestImgWithSrcsetAttribute(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -132,7 +132,7 @@ func TestImgWithSrcsetAttribute(t *testing.T) {
func TestImgWithSrcsetAndNoSrcAttribute(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -142,7 +142,7 @@ func TestImgWithSrcsetAndNoSrcAttribute(t *testing.T) {
func TestSourceWithSrcsetAndMedia(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -152,7 +152,7 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {
func TestMediumImgWithSrcset(t *testing.T) {
input := `
`
expected := `
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
@@ -161,7 +161,7 @@ func TestMediumImgWithSrcset(t *testing.T) {
func TestSelfClosingTags(t *testing.T) {
input := `This
is a text
with an image:
.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if input != output {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
@@ -170,7 +170,7 @@ func TestSelfClosingTags(t *testing.T) {
func TestTable(t *testing.T) {
input := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if input != output {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
@@ -179,8 +179,8 @@ func TestTable(t *testing.T) {
func TestRelativeURL(t *testing.T) {
input := `This link is relative and this image:
`
- expected := `This link is relative and this image:
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is relative and this image:
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -189,8 +189,8 @@ func TestRelativeURL(t *testing.T) {
func TestProtocolRelativeURL(t *testing.T) {
input := `This link is relative.`
- expected := `This link is relative.`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is relative.`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -200,7 +200,7 @@ func TestProtocolRelativeURL(t *testing.T) {
func TestInvalidTag(t *testing.T) {
input := `My invalid tag.
`
expected := `My invalid tag.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -210,7 +210,7 @@ func TestInvalidTag(t *testing.T) {
func TestVideoTag(t *testing.T) {
input := `My valid .
`
expected := `My valid .
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -220,7 +220,7 @@ func TestVideoTag(t *testing.T) {
func TestAudioAndSourceTag(t *testing.T) {
input := `My music .
`
expected := `My music .
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -230,7 +230,7 @@ func TestAudioAndSourceTag(t *testing.T) {
func TestUnknownTag(t *testing.T) {
input := `My invalid tag.
`
expected := `My invalid tag.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -240,7 +240,7 @@ func TestUnknownTag(t *testing.T) {
func TestInvalidNestedTag(t *testing.T) {
input := `My invalid tag with some valid tag.
`
expected := `My invalid tag with some valid tag.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -250,7 +250,7 @@ func TestInvalidNestedTag(t *testing.T) {
func TestInvalidIFrame(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.com/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.com/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -260,7 +260,27 @@ func TestInvalidIFrame(t *testing.T) {
func TestIFrameWithChildElements(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.com/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.com/", input)
+
+ if expected != output {
+ t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+ }
+}
+
+func TestLinkWithTarget(t *testing.T) {
+ input := `This link is an anchor
`
+ expected := `This link is an anchor
`
+ output := SanitizeHTML("http://example.org/", input, &SanitizerOptions{OpenLinksInNewTab: true})
+
+ if expected != output {
+ t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+ }
+}
+
+func TestLinkWithNoTarget(t *testing.T) {
+ input := `This link is an anchor
`
+ expected := `This link is an anchor
`
+ output := SanitizeHTML("http://example.org/", input, &SanitizerOptions{OpenLinksInNewTab: false})
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -270,7 +290,7 @@ func TestIFrameWithChildElements(t *testing.T) {
func TestAnchorLink(t *testing.T) {
input := `This link is an anchor
`
expected := `This link is an anchor
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -280,7 +300,7 @@ func TestAnchorLink(t *testing.T) {
func TestInvalidURLScheme(t *testing.T) {
input := `This link is not valid
`
expected := `This link is not valid
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -289,8 +309,8 @@ func TestInvalidURLScheme(t *testing.T) {
func TestAPTURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -299,8 +319,8 @@ func TestAPTURIScheme(t *testing.T) {
func TestBitcoinURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -309,8 +329,8 @@ func TestBitcoinURIScheme(t *testing.T) {
func TestCallToURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -319,16 +339,16 @@ func TestCallToURIScheme(t *testing.T) {
func TestFeedURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
input = `This link is valid
`
- expected = `This link is valid
`
- output = Sanitize("http://example.org/", input)
+ expected = `This link is valid
`
+ output = SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -337,8 +357,8 @@ func TestFeedURIScheme(t *testing.T) {
func TestGeoURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -347,16 +367,16 @@ func TestGeoURIScheme(t *testing.T) {
func TestItunesURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
input = `This link is valid
`
- expected = `This link is valid
`
- output = Sanitize("http://example.org/", input)
+ expected = `This link is valid
`
+ output = SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -365,8 +385,8 @@ func TestItunesURIScheme(t *testing.T) {
func TestMagnetURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -375,8 +395,8 @@ func TestMagnetURIScheme(t *testing.T) {
func TestMailtoURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -385,24 +405,24 @@ func TestMailtoURIScheme(t *testing.T) {
func TestNewsURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
input = `This link is valid
`
- expected = `This link is valid
`
- output = Sanitize("http://example.org/", input)
+ expected = `This link is valid
`
+ output = SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
input = `This link is valid
`
- expected = `This link is valid
`
- output = Sanitize("http://example.org/", input)
+ expected = `This link is valid
`
+ output = SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -411,8 +431,8 @@ func TestNewsURIScheme(t *testing.T) {
func TestRTMPURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -421,16 +441,16 @@ func TestRTMPURIScheme(t *testing.T) {
func TestSIPURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
input = `This link is valid
`
- expected = `This link is valid
`
- output = Sanitize("http://example.org/", input)
+ expected = `This link is valid
`
+ output = SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -439,8 +459,8 @@ func TestSIPURIScheme(t *testing.T) {
func TestSkypeURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -449,8 +469,8 @@ func TestSkypeURIScheme(t *testing.T) {
func TestSpotifyURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -459,8 +479,8 @@ func TestSpotifyURIScheme(t *testing.T) {
func TestSteamURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -469,16 +489,16 @@ func TestSteamURIScheme(t *testing.T) {
func TestSubversionURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
input = `This link is valid
`
- expected = `This link is valid
`
- output = Sanitize("http://example.org/", input)
+ expected = `This link is valid
`
+ output = SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -487,8 +507,8 @@ func TestSubversionURIScheme(t *testing.T) {
func TestTelURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -497,8 +517,8 @@ func TestTelURIScheme(t *testing.T) {
func TestWebcalURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -507,8 +527,8 @@ func TestWebcalURIScheme(t *testing.T) {
func TestXMPPURIScheme(t *testing.T) {
input := `This link is valid
`
- expected := `This link is valid
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link is valid
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -518,7 +538,7 @@ func TestXMPPURIScheme(t *testing.T) {
func TestBlacklistedLink(t *testing.T) {
input := `This image is not valid 
`
expected := `This image is not valid
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -527,8 +547,8 @@ func TestBlacklistedLink(t *testing.T) {
func TestLinkWithTrackers(t *testing.T) {
input := `This link has trackers Test
`
- expected := `This link has trackers Test
`
- output := Sanitize("http://example.org/", input)
+ expected := `This link has trackers Test
`
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -538,7 +558,7 @@ func TestLinkWithTrackers(t *testing.T) {
func TestImageSrcWithTrackers(t *testing.T) {
input := `This image has trackers 
`
expected := `This image has trackers 
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -548,7 +568,7 @@ func TestImageSrcWithTrackers(t *testing.T) {
func TestPixelTracker(t *testing.T) {
input := `
and 
`
expected := ` and
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -558,7 +578,7 @@ func TestPixelTracker(t *testing.T) {
func TestXmlEntities(t *testing.T) {
input := `echo "test" > /etc/hosts
`
expected := `echo "test" > /etc/hosts
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -568,7 +588,7 @@ func TestXmlEntities(t *testing.T) {
func TestEspaceAttributes(t *testing.T) {
input := `test | `
expected := `test | `
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -578,7 +598,7 @@ func TestEspaceAttributes(t *testing.T) {
func TestReplaceYoutubeURL(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -588,7 +608,7 @@ func TestReplaceYoutubeURL(t *testing.T) {
func TestReplaceSecureYoutubeURL(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -598,7 +618,7 @@ func TestReplaceSecureYoutubeURL(t *testing.T) {
func TestReplaceSecureYoutubeURLWithParameters(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -608,7 +628,7 @@ func TestReplaceSecureYoutubeURLWithParameters(t *testing.T) {
func TestReplaceYoutubeURLAlreadyReplaced(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -618,7 +638,7 @@ func TestReplaceYoutubeURLAlreadyReplaced(t *testing.T) {
func TestReplaceProtocolRelativeYoutubeURL(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -639,7 +659,7 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -649,7 +669,7 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
func TestReplaceIframeVimedoDNTURL(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -659,7 +679,7 @@ func TestReplaceIframeVimedoDNTURL(t *testing.T) {
func TestReplaceNoScript(t *testing.T) {
input := `Before paragraph.
After paragraph.
`
expected := `Before paragraph.
After paragraph.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -669,7 +689,7 @@ func TestReplaceNoScript(t *testing.T) {
func TestReplaceScript(t *testing.T) {
input := `Before paragraph.
After paragraph.
`
expected := `Before paragraph.
After paragraph.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -679,7 +699,7 @@ func TestReplaceScript(t *testing.T) {
func TestReplaceStyle(t *testing.T) {
input := `Before paragraph.
After paragraph.
`
expected := `Before paragraph.
After paragraph.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -689,7 +709,7 @@ func TestReplaceStyle(t *testing.T) {
func TestHiddenParagraph(t *testing.T) {
input := `Before paragraph.
This should not appear in the output
After paragraph.
`
expected := `Before paragraph.
After paragraph.
`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
@@ -700,7 +720,7 @@ func TestAttributesAreStripped(t *testing.T) {
input := `Some text.
Test.
`
expected := `Some text.
Test.`
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
@@ -709,7 +729,7 @@ func TestAttributesAreStripped(t *testing.T) {
func TestMathML(t *testing.T) {
input := ``
expected := ``
- output := Sanitize("http://example.org/", input)
+ output := SanitizeHTMLWithDefaultOptions("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
diff --git a/internal/storage/user.go b/internal/storage/user.go
index 97d69756..3a0a021a 100644
--- a/internal/storage/user.go
+++ b/internal/storage/user.go
@@ -97,7 +97,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
media_playback_rate,
block_filter_entry_rules,
keep_filter_entry_rules,
- always_open_external_links
+ always_open_external_links,
+ open_external_links_in_new_tab
`
tx, err := s.db.Begin()
@@ -142,6 +143,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
&user.BlockFilterEntryRules,
&user.KeepFilterEntryRules,
&user.AlwaysOpenExternalLinks,
+ &user.OpenExternalLinksInNewTab,
)
if err != nil {
tx.Rollback()
@@ -207,9 +209,10 @@ func (s *Storage) UpdateUser(user *model.User) error {
media_playback_rate=$26,
block_filter_entry_rules=$27,
keep_filter_entry_rules=$28,
- always_open_external_links=$29
+ always_open_external_links=$29,
+ open_external_links_in_new_tab=$30
WHERE
- id=$30
+ id=$31
`
_, err = s.db.Exec(
@@ -243,6 +246,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.BlockFilterEntryRules,
user.KeepFilterEntryRules,
user.AlwaysOpenExternalLinks,
+ user.OpenExternalLinksInNewTab,
user.ID,
)
if err != nil {
@@ -278,9 +282,10 @@ func (s *Storage) UpdateUser(user *model.User) error {
media_playback_rate=$25,
block_filter_entry_rules=$26,
keep_filter_entry_rules=$27,
- always_open_external_links=$28
+ always_open_external_links=$28,
+ open_external_links_in_new_tab=$29
WHERE
- id=$29
+ id=$30
`
_, err := s.db.Exec(
@@ -313,6 +318,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.BlockFilterEntryRules,
user.KeepFilterEntryRules,
user.AlwaysOpenExternalLinks,
+ user.OpenExternalLinksInNewTab,
user.ID,
)
@@ -367,7 +373,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
media_playback_rate,
block_filter_entry_rules,
keep_filter_entry_rules,
- always_open_external_links
+ always_open_external_links,
+ open_external_links_in_new_tab
FROM
users
WHERE
@@ -409,7 +416,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
media_playback_rate,
block_filter_entry_rules,
keep_filter_entry_rules,
- always_open_external_links
+ always_open_external_links,
+ open_external_links_in_new_tab
FROM
users
WHERE
@@ -451,7 +459,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
media_playback_rate,
block_filter_entry_rules,
keep_filter_entry_rules,
- always_open_external_links
+ always_open_external_links,
+ open_external_links_in_new_tab
FROM
users
WHERE
@@ -500,7 +509,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
media_playback_rate,
u.block_filter_entry_rules,
u.keep_filter_entry_rules,
- u.always_open_external_links
+ u.always_open_external_links,
+ u.open_external_links_in_new_tab
FROM
users u
LEFT JOIN
@@ -544,6 +554,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
&user.BlockFilterEntryRules,
&user.KeepFilterEntryRules,
&user.AlwaysOpenExternalLinks,
+ &user.OpenExternalLinksInNewTab,
)
if err == sql.ErrNoRows {
@@ -658,7 +669,8 @@ func (s *Storage) Users() (model.Users, error) {
media_playback_rate,
block_filter_entry_rules,
keep_filter_entry_rules,
- always_open_external_links
+ always_open_external_links,
+ open_external_links_in_new_tab
FROM
users
ORDER BY username ASC
@@ -703,6 +715,7 @@ func (s *Storage) Users() (model.Users, error) {
&user.BlockFilterEntryRules,
&user.KeepFilterEntryRules,
&user.AlwaysOpenExternalLinks,
+ &user.OpenExternalLinksInNewTab,
)
if err != nil {
diff --git a/internal/template/templates/common/feed_list.html b/internal/template/templates/common/feed_list.html
index 3da5da9c..a72cfb1d 100644
--- a/internal/template/templates/common/feed_list.html
+++ b/internal/template/templates/common/feed_list.html
@@ -37,7 +37,7 @@
@@ -77,7 +77,7 @@
{{ t "form.feed.label.rewrite_rules" }}
-
+
{{ icon "external-link" }}
@@ -87,7 +87,7 @@
{{ t "form.feed.label.blocklist_rules" }}
-
+
{{ icon "external-link" }}
@@ -98,7 +98,7 @@
{{ t "form.feed.label.keeplist_rules" }}
-
+
{{ icon "external-link" }}
@@ -109,7 +109,7 @@
{{ t "form.feed.label.urlrewrite_rules" }}
-
+
{{ icon "external-link" }}
diff --git a/internal/template/templates/views/choose_subscription.html b/internal/template/templates/views/choose_subscription.html
index c8034a2b..e9597dcc 100644
--- a/internal/template/templates/views/choose_subscription.html
+++ b/internal/template/templates/views/choose_subscription.html
@@ -39,7 +39,7 @@
{{ range .subscriptions }}
{{ end }}
diff --git a/internal/template/templates/views/edit_feed.html b/internal/template/templates/views/edit_feed.html
index b55ad754..8921d18a 100644
--- a/internal/template/templates/views/edit_feed.html
+++ b/internal/template/templates/views/edit_feed.html
@@ -124,7 +124,7 @@
{{ t "form.feed.label.scraper_rules" }}
-
+
{{ icon "external-link" }}
@@ -135,7 +135,7 @@
{{ t "form.feed.label.rewrite_rules" }}
-
+
{{ icon "external-link" }}
@@ -145,7 +145,7 @@
{{ t "form.feed.label.blocklist_rules" }}
-
+
{{ icon "external-link" }}
@@ -156,7 +156,7 @@
{{ t "form.feed.label.keeplist_rules" }}
-
+
{{ icon "external-link" }}
@@ -167,7 +167,7 @@
{{ t "form.feed.label.urlrewrite_rules" }}
-
+
{{ icon "external-link" }}
@@ -205,7 +205,7 @@
{{ t "form.feed.label.ntfy_priority" }}
-
+
{{ icon "external-link" }}
@@ -226,7 +226,7 @@
{{ t "form.feed.label.pushover_priority" }}
-
+
{{ icon "external-link" }}
diff --git a/internal/template/templates/views/entry.html b/internal/template/templates/views/entry.html
index f5c33f0a..0ef05a20 100644
--- a/internal/template/templates/views/entry.html
+++ b/internal/template/templates/views/entry.html
@@ -4,7 +4,7 @@