瀏覽代碼

feat: add new settings option to allow external fonts

Frédéric Guillot 1 年之前
父節點
當前提交
e555e442fb

+ 2 - 0
client/model.go

@@ -45,6 +45,7 @@ type User struct {
 	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"`
 }
 
 func (u User) String() string {
@@ -88,6 +89,7 @@ type UserModificationRequest struct {
 	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"`
 }
 
 // Users represents a list of users.

+ 53 - 0
internal/api/api_integration_test.go

@@ -592,6 +592,59 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
 	}
 }
 
+func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
+	testConfig := newIntegrationTestConfig()
+	if !testConfig.isConfigured() {
+		t.Skip(skipIntegrationTestsMessage)
+	}
+
+	adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
+	regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer adminClient.DeleteUser(regularTestUser.ID)
+
+	regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
+
+	userUpdateRequest := &miniflux.UserModificationRequest{
+		ExternalFontHosts: miniflux.SetOptionalField("  fonts.example.org  "),
+	}
+
+	updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if updatedUser.ExternalFontHosts != "fonts.example.org" {
+		t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
+	}
+}
+
+func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {
+	testConfig := newIntegrationTestConfig()
+	if !testConfig.isConfigured() {
+		t.Skip(skipIntegrationTestsMessage)
+	}
+
+	adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
+	regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer adminClient.DeleteUser(regularTestUser.ID)
+
+	regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
+
+	userUpdateRequest := &miniflux.UserModificationRequest{
+		ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
+	}
+
+	if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
+		t.Fatal(`Updating the user with an invalid external font host should raise an error`)
+	}
+}
+
 func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
 	testConfig := newIntegrationTestConfig()
 	if !testConfig.isConfigured() {

+ 5 - 0
internal/database/migrations.go

@@ -947,4 +947,9 @@ var migrations = []func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 4 - 1
internal/locale/translations/de_DE.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
     "form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
     "form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
-    "form.prefs.label.custom_js": "Benutzerdefiniertes JS",
+    "form.prefs.label.custom_js": "Benutzerdefiniertes JavaScript",
     "form.prefs.label.entry_order": "Artikel-Sortierspalte",
     "form.prefs.label.default_home_page": "Standard-Startseite",
     "form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
     "form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML Datei",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/el_EL.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
     "form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
     "form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
-    "form.prefs.label.custom_js": "Προσαρμοσμένο JS",
+    "form.prefs.label.custom_js": "Προσαρμοσμένο JavaScript",
     "form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
     "form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
     "form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "Αρχείο OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/en_US.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Gesture to navigate between entries",
     "form.prefs.label.show_reading_time": "Show estimated reading time for entries",
     "form.prefs.label.custom_css": "Custom CSS",
-    "form.prefs.label.custom_js": "Custom JS",
+    "form.prefs.label.custom_js": "Custom JavaScript",
     "form.prefs.label.entry_order": "Entry sorting column",
     "form.prefs.label.default_home_page": "Default home page",
     "form.prefs.label.categories_sorting_order": "Categories sorting",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML file",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/es_ES.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
     "form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
     "form.prefs.label.custom_css": "CSS personalizado",
-    "form.prefs.label.custom_js": "JS personalizado",
+    "form.prefs.label.custom_js": "JavaScript personalizado",
     "form.prefs.label.entry_order": "Columna de clasificación de artículos",
     "form.prefs.label.default_home_page": "Página de inicio por defecto",
     "form.prefs.label.categories_sorting_order": "Clasificación por categorías",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
     "form.prefs.fieldset.reader_settings": "Ajustes del lector",
     "form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "Archivo OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Guardar artículos en Betula",

+ 4 - 1
internal/locale/translations/fi_FI.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
     "form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
     "form.prefs.label.custom_css": "Mukautettu CSS",
-    "form.prefs.label.custom_js": "Mukautettu JS",
+    "form.prefs.label.custom_js": "Mukautettu JavaScript",
     "form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
     "form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
     "form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML-tiedosto",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/fr_FR.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
     "form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
     "form.prefs.label.custom_css": "Feuille de style personnalisée",
-    "form.prefs.label.custom_js": "Script personnalisée",
+    "form.prefs.label.custom_js": "Code JavaScript personnalisé",
     "form.prefs.label.entry_order": "Colonne de tri des entrées",
     "form.prefs.label.default_home_page": "Page d'accueil par défaut",
     "form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
     "form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
     "form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
+    "form.prefs.label.external_font_hosts": "Polices externes autorisées",
+    "form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
+    "error.settings_invalid_domain_list": "Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.",
     "form.import.label.file": "Fichier OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Sauvegarder les entrées vers Betula",

+ 3 - 0
internal/locale/translations/hi_IN.json

@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "ओपीएमएल फ़ाइल",
     "form.import.label.url": "यूआरएल",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/id_ID.json

@@ -381,7 +381,7 @@
     "form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
     "form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
     "form.prefs.label.custom_css": "Modifikasi CSS",
-    "form.prefs.label.custom_js": "Modifikasi JS",
+    "form.prefs.label.custom_js": "Modifikasi JavaScript",
     "form.prefs.label.entry_order": "Pengurutan Kolom Entri",
     "form.prefs.label.default_home_page": "Beranda Baku",
     "form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
@@ -393,6 +393,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "Berkas OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/it_IT.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
     "form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
     "form.prefs.label.custom_css": "CSS personalizzati",
-    "form.prefs.label.custom_js": "JS personalizzati",
+    "form.prefs.label.custom_js": "JavaScript personalizzati",
     "form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
     "form.prefs.label.default_home_page": "Pagina iniziale predefinita",
     "form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "File OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/ja_JP.json

@@ -381,7 +381,7 @@
     "form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
     "form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
     "form.prefs.label.custom_css": "カスタム CSS",
-    "form.prefs.label.custom_js": "カスタム JS",
+    "form.prefs.label.custom_js": "カスタム JavaScript",
     "form.prefs.label.entry_order": "記事の表示順の基準",
     "form.prefs.label.default_home_page": "デフォルトのトップページ",
     "form.prefs.label.categories_sorting_order": "カテゴリの表示順",
@@ -393,6 +393,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML ファイル",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/nl_NL.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
     "form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
     "form.prefs.label.custom_css": "Aangepaste CSS",
-    "form.prefs.label.custom_js": "Aangepaste JS",
+    "form.prefs.label.custom_js": "Aangepaste JavaScript",
     "form.prefs.label.entry_order": "Artikelen sorteren",
     "form.prefs.label.default_home_page": "Startpagina",
     "form.prefs.label.categories_sorting_order": "Volgorde categorieën",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
     "form.prefs.fieldset.reader_settings": "Lees Instellingen",
     "form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML-bestand",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Artikelen opslaan in Betula",

+ 4 - 1
internal/locale/translations/pl_PL.json

@@ -401,7 +401,7 @@
     "form.prefs.select.tap": "Podwójne wciśnięcie",
     "form.prefs.select.swipe": "Trzepnąć",
     "form.prefs.label.custom_css": "Niestandardowy CSS",
-    "form.prefs.label.custom_js": "Niestandardowy JS",
+    "form.prefs.label.custom_js": "Niestandardowy JavaScript",
     "form.prefs.label.entry_order": "Kolumna sortowania wpisów",
     "form.prefs.label.default_home_page": "Domyślna strona główna",
     "form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
@@ -413,6 +413,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "Plik OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/pt_BR.json

@@ -391,7 +391,7 @@
     "form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
     "form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
     "form.prefs.label.custom_css": "CSS customizado",
-    "form.prefs.label.custom_js": "JS customizado",
+    "form.prefs.label.custom_js": "JavaScript customizado",
     "form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
     "form.prefs.label.default_home_page": "Página inicial predefinida",
     "form.prefs.label.categories_sorting_order": "Classificação das categorias",
@@ -403,6 +403,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "Arquivo OPML",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/ru_RU.json

@@ -401,7 +401,7 @@
     "form.prefs.label.gesture_nav": "Жест для перехода между статьями",
     "form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
     "form.prefs.label.custom_css": "Пользовательский CSS",
-    "form.prefs.label.custom_js": "Пользовательский JS",
+    "form.prefs.label.custom_js": "Пользовательский JavaScript",
     "form.prefs.label.entry_order": "Столбец сортировки статей",
     "form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
     "form.prefs.label.categories_sorting_order": "Сортировка категорий",
@@ -413,6 +413,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML файл",
     "form.import.label.url": "Ссылка",
     "form.integration.betula_activate": "Сохранять статьи в Бетулу",

+ 4 - 1
internal/locale/translations/tr_TR.json

@@ -293,7 +293,7 @@
   "form.prefs.label.categories_sorting_order": "Kategori sıralaması",
   "form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
   "form.prefs.label.custom_css": "Özel CSS",
-  "form.prefs.label.custom_js": "Özel JS",
+  "form.prefs.label.custom_js": "Özel JavaScript",
   "form.prefs.label.default_home_page": "Varsayılan ana sayfa",
   "form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
   "form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
@@ -325,6 +325,9 @@
   "form.prefs.select.swipe": "Kaydırma",
   "form.prefs.select.tap": "Çift dokunma",
   "form.prefs.select.unread_count": "Okunmamış sayısı",
+  "form.prefs.label.external_font_hosts": "External font hosts",
+  "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+  "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
   "form.submit.loading": "Yükleniyor...",
   "form.submit.saving": "Kaydediliyor...",
   "form.user.label.admin": "Yönetici",

+ 4 - 1
internal/locale/translations/uk_UA.json

@@ -401,7 +401,7 @@
     "form.prefs.label.gesture_nav": "Жест для переходу між записами",
     "form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
     "form.prefs.label.custom_css": "Спеціальний CSS",
-    "form.prefs.label.custom_js": "Спеціальний JS",
+    "form.prefs.label.custom_js": "Спеціальний JavaScript",
     "form.prefs.label.entry_order": "Стовпець сортування записів",
     "form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
     "form.prefs.label.categories_sorting_order": "Сортування за категоріями",
@@ -413,6 +413,9 @@
     "form.prefs.fieldset.authentication_settings": "Authentication Settings",
     "form.prefs.fieldset.reader_settings": "Reader Settings",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "Файл OPML",
     "form.import.label.url": "URL-адреса",
     "form.integration.betula_activate": "Save entries to Betula",

+ 4 - 1
internal/locale/translations/zh_CN.json

@@ -381,7 +381,7 @@
     "form.prefs.label.gesture_nav": "在条目之间导航的手势",
     "form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
     "form.prefs.label.custom_css": "自定义 CSS",
-    "form.prefs.label.custom_js": "自定义 JS",
+    "form.prefs.label.custom_js": "自定义 JavaScript",
     "form.prefs.label.entry_order": "文章排序依据",
     "form.prefs.label.default_home_page": "默认主页",
     "form.prefs.label.categories_sorting_order": "分类排序",
@@ -393,6 +393,9 @@
     "form.prefs.fieldset.authentication_settings": "用户认证设置",
     "form.prefs.fieldset.reader_settings": "阅读器设置",
     "form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML 文件",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "保存文章到 Betula",

+ 4 - 1
internal/locale/translations/zh_TW.json

@@ -381,7 +381,7 @@
     "form.prefs.label.gesture_nav": "在條目之間導航的手勢",
     "form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
     "form.prefs.label.custom_css": "自定義 CSS",
-    "form.prefs.label.custom_js": "自定義 JS",
+    "form.prefs.label.custom_js": "自定義 JavaScript",
     "form.prefs.label.entry_order": "文章排序依據",
     "form.prefs.label.default_home_page": "預設主頁",
     "form.prefs.label.categories_sorting_order": "分類排序",
@@ -393,6 +393,9 @@
     "form.prefs.fieldset.authentication_settings": "使用者認證設定",
     "form.prefs.fieldset.reader_settings": "閱讀器設定",
     "form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
+    "form.prefs.label.external_font_hosts": "External font hosts",
+    "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
+    "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
     "form.import.label.file": "OPML 檔案",
     "form.import.label.url": "URL",
     "form.integration.betula_activate": "Save entries to Betula",

+ 6 - 0
internal/model/user.go

@@ -22,6 +22,7 @@ type User struct {
 	EntryOrder                      string     `json:"entry_sorting_order"`
 	Stylesheet                      string     `json:"stylesheet"`
 	CustomJS                        string     `json:"custom_js"`
+	ExternalFontHosts               string     `json:"external_font_hosts"`
 	GoogleID                        string     `json:"google_id"`
 	OpenIDConnectID                 string     `json:"openid_connect_id"`
 	EntriesPerPage                  int        `json:"entries_per_page"`
@@ -62,6 +63,7 @@ type UserModificationRequest struct {
 	EntryOrder                      *string  `json:"entry_sorting_order"`
 	Stylesheet                      *string  `json:"stylesheet"`
 	CustomJS                        *string  `json:"custom_js"`
+	ExternalFontHosts               *string  `json:"external_font_hosts"`
 	GoogleID                        *string  `json:"google_id"`
 	OpenIDConnectID                 *string  `json:"openid_connect_id"`
 	EntriesPerPage                  *int     `json:"entries_per_page"`
@@ -124,6 +126,10 @@ func (u *UserModificationRequest) Patch(user *User) {
 		user.CustomJS = *u.CustomJS
 	}
 
+	if u.ExternalFontHosts != nil {
+		user.ExternalFontHosts = *u.ExternalFontHosts
+	}
+
 	if u.GoogleID != nil {
 		user.GoogleID = *u.GoogleID
 	}

+ 43 - 28
internal/storage/user.go

@@ -84,6 +84,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 			gesture_nav,
 			stylesheet,
 			custom_js,
+			external_font_hosts,
 			google_id,
 			openid_connect_id,
 			display_mode,
@@ -126,6 +127,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 		&user.GestureNav,
 		&user.Stylesheet,
 		&user.CustomJS,
+		&user.ExternalFontHosts,
 		&user.GoogleID,
 		&user.OpenIDConnectID,
 		&user.DisplayMode,
@@ -165,6 +167,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 
 // UpdateUser updates a user.
 func (s *Storage) UpdateUser(user *model.User) error {
+	user.ExternalFontHosts = strings.TrimSpace(user.ExternalFontHosts)
+
 	if user.Password != "" {
 		hashedPassword, err := crypto.HashPassword(user.Password)
 		if err != nil {
@@ -187,21 +191,22 @@ func (s *Storage) UpdateUser(user *model.User) error {
 				gesture_nav=$12,
 				stylesheet=$13,
 				custom_js=$14,
-				google_id=$15,
-				openid_connect_id=$16,
-				display_mode=$17,
-				entry_order=$18,
-				default_reading_speed=$19,
-				cjk_reading_speed=$20,
-				default_home_page=$21,
-				categories_sorting_order=$22,
-				mark_read_on_view=$23,
-				mark_read_on_media_player_completion=$24,
-				media_playback_rate=$25,
-				block_filter_entry_rules=$26,
-				keep_filter_entry_rules=$27
+				external_font_hosts=$15,
+				google_id=$16,
+				openid_connect_id=$167,
+				display_mode=$18,
+				entry_order=$19,
+				default_reading_speed=$20,
+				cjk_reading_speed=$21,
+				default_home_page=$22,
+				categories_sorting_order=$23,
+				mark_read_on_view=$24,
+				mark_read_on_media_player_completion=$25,
+				media_playback_rate=$26,
+				block_filter_entry_rules=$27,
+				keep_filter_entry_rules=$28
 			WHERE
-				id=$28
+				id=$29
 		`
 
 		_, err = s.db.Exec(
@@ -220,6 +225,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 			user.GestureNav,
 			user.Stylesheet,
 			user.CustomJS,
+			user.ExternalFontHosts,
 			user.GoogleID,
 			user.OpenIDConnectID,
 			user.DisplayMode,
@@ -254,21 +260,22 @@ func (s *Storage) UpdateUser(user *model.User) error {
 				gesture_nav=$11,
 				stylesheet=$12,
 				custom_js=$13,
-				google_id=$14,
-				openid_connect_id=$15,
-				display_mode=$16,
-				entry_order=$17,
-				default_reading_speed=$18,
-				cjk_reading_speed=$19,
-				default_home_page=$20,
-				categories_sorting_order=$21,
-				mark_read_on_view=$22,
-				mark_read_on_media_player_completion=$23,
-				media_playback_rate=$24,
-				block_filter_entry_rules=$25,
-				keep_filter_entry_rules=$26
+				external_font_hosts=$14,
+				google_id=$15,
+				openid_connect_id=$16,
+				display_mode=$17,
+				entry_order=$18,
+				default_reading_speed=$19,
+				cjk_reading_speed=$20,
+				default_home_page=$21,
+				categories_sorting_order=$22,
+				mark_read_on_view=$23,
+				mark_read_on_media_player_completion=$24,
+				media_playback_rate=$25,
+				block_filter_entry_rules=$26,
+				keep_filter_entry_rules=$27
 			WHERE
-				id=$27
+				id=$28
 		`
 
 		_, err := s.db.Exec(
@@ -286,6 +293,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 			user.GestureNav,
 			user.Stylesheet,
 			user.CustomJS,
+			user.ExternalFontHosts,
 			user.GoogleID,
 			user.OpenIDConnectID,
 			user.DisplayMode,
@@ -339,6 +347,7 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
 			last_login_at,
 			stylesheet,
 			custom_js,
+			external_font_hosts,
 			google_id,
 			openid_connect_id,
 			display_mode,
@@ -379,6 +388,7 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
 			last_login_at,
 			stylesheet,
 			custom_js,
+			external_font_hosts,
 			google_id,
 			openid_connect_id,
 			display_mode,
@@ -419,6 +429,7 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
 			last_login_at,
 			stylesheet,
 			custom_js,
+			external_font_hosts,
 			google_id,
 			openid_connect_id,
 			display_mode,
@@ -466,6 +477,7 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
 			u.last_login_at,
 			u.stylesheet,
 			u.custom_js,
+			u.external_font_hosts,
 			u.google_id,
 			u.openid_connect_id,
 			u.display_mode,
@@ -507,6 +519,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
 		&user.LastLoginAt,
 		&user.Stylesheet,
 		&user.CustomJS,
+		&user.ExternalFontHosts,
 		&user.GoogleID,
 		&user.OpenIDConnectID,
 		&user.DisplayMode,
@@ -620,6 +633,7 @@ func (s *Storage) Users() (model.Users, error) {
 			last_login_at,
 			stylesheet,
 			custom_js,
+			external_font_hosts,
 			google_id,
 			openid_connect_id,
 			display_mode,
@@ -662,6 +676,7 @@ func (s *Storage) Users() (model.Users, error) {
 			&user.LastLoginAt,
 			&user.Stylesheet,
 			&user.CustomJS,
+			&user.ExternalFontHosts,
 			&user.GoogleID,
 			&user.OpenIDConnectID,
 			&user.DisplayMode,

+ 11 - 9
internal/template/templates/common/layout.html

@@ -35,16 +35,18 @@
     <link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
 
     {{ if .user }}
-    {{ $cspNonce := nonce }}
-    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; style-src 'self'{{ if .user.Stylesheet }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types ttpolicy;">
-    {{ if .user.Stylesheet }}
-    <style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
-    {{ end }}
-    {{ if .user.CustomJS }}
-    <script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
-    {{ end }}
+        {{ $cspNonce := nonce }}
+        <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types ttpolicy;">
+
+        {{ if .user.Stylesheet }}
+        <style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
+        {{ end }}
+
+        {{ if .user.CustomJS }}
+        <script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
+        {{ end }}
     {{ else }}
-    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
+        <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
     {{ end }}
 
     <script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script>

+ 4 - 0
internal/template/templates/views/settings.html

@@ -210,6 +210,10 @@
         <label for="form-custom-css">{{t "form.prefs.label.custom_css" }}</label>
         <textarea id="form-custom-css" name="custom_css" cols="40" rows="10" spellcheck="false">{{ .form.CustomCSS }}</textarea>
 
+        <label for="form-external-font-hosts">{{t "form.prefs.label.external_font_hosts" }}</label>
+        <input type="text" id="form-external-font-hosts" name="external_font_hosts" spellcheck="false" value="{{ .form.ExternalFontHosts }}">
+        <div class="form-help">{{t "form.prefs.help.external_font_hosts" }}</div>
+
         <label for="form-custom-js">{{t "form.prefs.label.custom_js" }}</label>
         <textarea id="form-custom-js" name="custom_js" cols="40" rows="10" spellcheck="false">{{ .form.CustomJS }}</textarea>
 

+ 10 - 0
internal/ui/form/settings.go

@@ -10,6 +10,7 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/validator"
 )
 
 // MarkReadBehavior list all possible behaviors for automatically marking an entry as read
@@ -37,6 +38,7 @@ type SettingsForm struct {
 	ShowReadingTime        bool
 	CustomCSS              string
 	CustomJS               string
+	ExternalFontHosts      string
 	EntrySwipe             bool
 	GestureNav             string
 	DisplayMode            string
@@ -101,6 +103,7 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
 	user.ShowReadingTime = s.ShowReadingTime
 	user.Stylesheet = s.CustomCSS
 	user.CustomJS = s.CustomJS
+	user.ExternalFontHosts = s.ExternalFontHosts
 	user.EntrySwipe = s.EntrySwipe
 	user.GestureNav = s.GestureNav
 	user.DisplayMode = s.DisplayMode
@@ -148,6 +151,12 @@ func (s *SettingsForm) Validate() *locale.LocalizedError {
 		return locale.NewLocalizedError("error.settings_media_playback_rate_range")
 	}
 
+	if s.ExternalFontHosts != "" {
+		if !validator.IsValidDomainList(s.ExternalFontHosts) {
+			return locale.NewLocalizedError("error.settings_invalid_domain_list")
+		}
+	}
+
 	return nil
 }
 
@@ -183,6 +192,7 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
 		ShowReadingTime:        r.FormValue("show_reading_time") == "1",
 		CustomCSS:              r.FormValue("custom_css"),
 		CustomJS:               r.FormValue("custom_js"),
+		ExternalFontHosts:      r.FormValue("external_font_hosts"),
 		EntrySwipe:             r.FormValue("entry_swipe") == "1",
 		GestureNav:             r.FormValue("gesture_nav"),
 		DisplayMode:            r.FormValue("display_mode"),

+ 1 - 0
internal/ui/settings_show.go

@@ -34,6 +34,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
 		ShowReadingTime:        user.ShowReadingTime,
 		CustomCSS:              user.Stylesheet,
 		CustomJS:               user.CustomJS,
+		ExternalFontHosts:      user.ExternalFontHosts,
 		EntrySwipe:             user.EntrySwipe,
 		GestureNav:             user.GestureNav,
 		DisplayMode:            user.DisplayMode,

+ 1 - 0
internal/ui/settings_update.go

@@ -85,6 +85,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
 		MediaPlaybackRate:     model.OptionalNumber(settingsForm.MediaPlaybackRate),
 		BlockFilterEntryRules: model.OptionalString(settingsForm.BlockFilterEntryRules),
 		KeepFilterEntryRules:  model.OptionalString(settingsForm.KeepFilterEntryRules),
+		ExternalFontHosts:     model.OptionalString(settingsForm.ExternalFontHosts),
 	}
 
 	if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {

+ 2 - 2
internal/ui/static/css/common.css

@@ -427,7 +427,6 @@ input[type="number"] {
     line-height: 20px;
     width: 250px;
     font-size: 99%;
-    margin-bottom: 10px;
     margin-top: 5px;
     appearance: none;
 }
@@ -448,7 +447,8 @@ input[type="number"]:focus {
 }
 
 input[type="checkbox"] {
-    margin-bottom: 15px;
+    margin-top: 10px;
+    margin-bottom: 10px;
 }
 
 textarea {

+ 6 - 0
internal/validator/user.go

@@ -123,6 +123,12 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
 		}
 	}
 
+	if changes.ExternalFontHosts != nil {
+		if !IsValidDomainList(*changes.ExternalFontHosts) {
+			return locale.NewLocalizedError("error.settings_invalid_domain_list")
+		}
+	}
+
 	return nil
 }
 

+ 24 - 0
internal/validator/validator.go

@@ -7,8 +7,11 @@ import (
 	"fmt"
 	"net/url"
 	"regexp"
+	"strings"
 )
 
+var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
+
 // ValidateRange makes sure the offset/limit values are valid.
 func ValidateRange(offset, limit int) error {
 	if offset < 0 {
@@ -43,3 +46,24 @@ func IsValidURL(absoluteURL string) bool {
 	_, err := url.ParseRequestURI(absoluteURL)
 	return err == nil
 }
+
+func IsValidDomain(domain string) bool {
+	domain = strings.ToLower(domain)
+
+	if len(domain) < 1 || len(domain) > 253 {
+		return false
+	}
+
+	return domainRegex.MatchString(domain)
+}
+
+func IsValidDomainList(value string) bool {
+	domains := strings.Split(strings.TrimSpace(value), " ")
+	for _, domain := range domains {
+		if !IsValidDomain(domain) {
+			return false
+		}
+	}
+
+	return true
+}

+ 18 - 0
internal/validator/validator_test.go

@@ -59,3 +59,21 @@ func TestIsValidRegex(t *testing.T) {
 		}
 	}
 }
+
+func TestIsValidDomain(t *testing.T) {
+	scenarios := map[string]bool{
+		"example.org":          true,
+		"example":              false,
+		"example.":             false,
+		"example..":            false,
+		"mail.example.com:443": false,
+		"*.example.com":        false,
+	}
+
+	for domain, expected := range scenarios {
+		result := IsValidDomain(domain)
+		if result != expected {
+			t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
+		}
+	}
+}