diff --git a/api/api.go b/api/api.go index 4e6ef7b8..008b4c00 100644 --- a/api/api.go +++ b/api/api.go @@ -36,7 +36,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods(http.MethodPut) sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods(http.MethodDelete) sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Methods(http.MethodPut) - sr.HandleFunc("/discover", handler.getSubscriptions).Methods(http.MethodPost) + sr.HandleFunc("/discover", handler.discoverSubscriptions).Methods(http.MethodPost) sr.HandleFunc("/feeds", handler.createFeed).Methods(http.MethodPost) sr.HandleFunc("/feeds", handler.getFeeds).Methods(http.MethodGet) sr.HandleFunc("/feeds/refresh", handler.refreshAllFeeds).Methods(http.MethodPut) diff --git a/api/feed.go b/api/feed.go index ca90944e..192aedfe 100644 --- a/api/feed.go +++ b/api/feed.go @@ -5,60 +5,32 @@ package api // import "miniflux.app/api" import ( - "errors" + json_parser "encoding/json" "net/http" "time" "miniflux.app/http/request" "miniflux.app/http/response/json" + "miniflux.app/model" feedHandler "miniflux.app/reader/handler" + "miniflux.app/validator" ) func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) { - feedInfo, err := decodeFeedCreationRequest(r.Body) - if err != nil { + userID := request.UserID(r) + + var feedCreationRequest model.FeedCreationRequest + if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil { json.BadRequest(w, r, err) return } - if feedInfo.FeedURL == "" { - json.BadRequest(w, r, errors.New("The feed_url is required")) + if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil { + json.BadRequest(w, r, validationErr.Error()) return } - if feedInfo.CategoryID <= 0 { - json.BadRequest(w, r, errors.New("The category_id is required")) - return - } - - userID := request.UserID(r) - - if h.store.FeedURLExists(userID, feedInfo.FeedURL) { - json.BadRequest(w, r, errors.New("This feed_url already exists")) - return - } - - if !h.store.CategoryIDExists(userID, feedInfo.CategoryID) { - json.BadRequest(w, r, errors.New("This category_id doesn't exists or doesn't belongs to this user")) - return - } - - feed, err := feedHandler.CreateFeed(h.store, &feedHandler.FeedCreationArgs{ - UserID: userID, - CategoryID: feedInfo.CategoryID, - FeedURL: feedInfo.FeedURL, - UserAgent: feedInfo.UserAgent, - Username: feedInfo.Username, - Password: feedInfo.Password, - Crawler: feedInfo.Crawler, - Disabled: feedInfo.Disabled, - IgnoreHTTPCache: feedInfo.IgnoreHTTPCache, - FetchViaProxy: feedInfo.FetchViaProxy, - ScraperRules: feedInfo.ScraperRules, - RewriteRules: feedInfo.RewriteRules, - BlocklistRules: feedInfo.BlocklistRules, - KeeplistRules: feedInfo.KeeplistRules, - }) + feed, err := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest) if err != nil { json.ServerError(w, r, err) return @@ -101,14 +73,14 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) { } func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { - feedID := request.RouteInt64Param(r, "feedID") - feedChanges, err := decodeFeedModificationRequest(r.Body) - if err != nil { + var feedModificationRequest model.FeedModificationRequest + if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil { json.BadRequest(w, r, err) return } userID := request.UserID(r) + feedID := request.RouteInt64Param(r, "feedID") originalFeed, err := h.store.FeedByID(userID, feedID) if err != nil { @@ -121,13 +93,12 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { return } - feedChanges.Update(originalFeed) - - if !h.store.CategoryIDExists(userID, originalFeed.Category.ID) { - json.BadRequest(w, r, errors.New("This category_id doesn't exists or doesn't belongs to this user")) + if validationErr := validator.ValidateFeedModification(h.store, userID, &feedModificationRequest); validationErr != nil { + json.BadRequest(w, r, validationErr.Error()) return } + feedModificationRequest.Patch(originalFeed) if err := h.store.UpdateFeed(originalFeed); err != nil { json.ServerError(w, r, err) return diff --git a/api/payload.go b/api/payload.go index b24c73ea..99700162 100644 --- a/api/payload.go +++ b/api/payload.go @@ -23,150 +23,10 @@ type entriesResponse struct { Entries model.Entries `json:"entries"` } -type subscriptionDiscoveryRequest struct { - URL string `json:"url"` - UserAgent string `json:"user_agent"` - Username string `json:"username"` - Password string `json:"password"` - FetchViaProxy bool `json:"fetch_via_proxy"` -} - -func decodeSubscriptionDiscoveryRequest(r io.ReadCloser) (*subscriptionDiscoveryRequest, error) { - defer r.Close() - - var s subscriptionDiscoveryRequest - decoder := json.NewDecoder(r) - if err := decoder.Decode(&s); err != nil { - return nil, fmt.Errorf("invalid JSON payload: %v", err) - } - - return &s, nil -} - type feedCreationResponse struct { FeedID int64 `json:"feed_id"` } -type feedCreationRequest struct { - FeedURL string `json:"feed_url"` - CategoryID int64 `json:"category_id"` - UserAgent string `json:"user_agent"` - Username string `json:"username"` - Password string `json:"password"` - Crawler bool `json:"crawler"` - Disabled bool `json:"disabled"` - IgnoreHTTPCache bool `json:"ignore_http_cache"` - FetchViaProxy bool `json:"fetch_via_proxy"` - ScraperRules string `json:"scraper_rules"` - RewriteRules string `json:"rewrite_rules"` - BlocklistRules string `json:"blocklist_rules"` - KeeplistRules string `json:"keeplist_rules"` -} - -func decodeFeedCreationRequest(r io.ReadCloser) (*feedCreationRequest, error) { - defer r.Close() - - var fc feedCreationRequest - decoder := json.NewDecoder(r) - if err := decoder.Decode(&fc); err != nil { - return nil, fmt.Errorf("Invalid JSON payload: %v", err) - } - - return &fc, nil -} - -type feedModificationRequest struct { - FeedURL *string `json:"feed_url"` - SiteURL *string `json:"site_url"` - Title *string `json:"title"` - ScraperRules *string `json:"scraper_rules"` - RewriteRules *string `json:"rewrite_rules"` - BlocklistRules *string `json:"blocklist_rules"` - KeeplistRules *string `json:"keeplist_rules"` - Crawler *bool `json:"crawler"` - UserAgent *string `json:"user_agent"` - Username *string `json:"username"` - Password *string `json:"password"` - CategoryID *int64 `json:"category_id"` - Disabled *bool `json:"disabled"` - IgnoreHTTPCache *bool `json:"ignore_http_cache"` - FetchViaProxy *bool `json:"fetch_via_proxy"` -} - -func (f *feedModificationRequest) Update(feed *model.Feed) { - if f.FeedURL != nil && *f.FeedURL != "" { - feed.FeedURL = *f.FeedURL - } - - if f.SiteURL != nil && *f.SiteURL != "" { - feed.SiteURL = *f.SiteURL - } - - if f.Title != nil && *f.Title != "" { - feed.Title = *f.Title - } - - if f.ScraperRules != nil { - feed.ScraperRules = *f.ScraperRules - } - - if f.RewriteRules != nil { - feed.RewriteRules = *f.RewriteRules - } - - if f.KeeplistRules != nil { - feed.KeeplistRules = *f.KeeplistRules - } - - if f.BlocklistRules != nil { - feed.BlocklistRules = *f.BlocklistRules - } - - if f.Crawler != nil { - feed.Crawler = *f.Crawler - } - - if f.UserAgent != nil { - feed.UserAgent = *f.UserAgent - } - - if f.Username != nil { - feed.Username = *f.Username - } - - if f.Password != nil { - feed.Password = *f.Password - } - - if f.CategoryID != nil && *f.CategoryID > 0 { - feed.Category.ID = *f.CategoryID - } - - if f.Disabled != nil { - feed.Disabled = *f.Disabled - } - - if f.IgnoreHTTPCache != nil { - feed.IgnoreHTTPCache = *f.IgnoreHTTPCache - } - - if f.FetchViaProxy != nil { - feed.FetchViaProxy = *f.FetchViaProxy - } -} - -func decodeFeedModificationRequest(r io.ReadCloser) (*feedModificationRequest, error) { - defer r.Close() - - var feed feedModificationRequest - decoder := json.NewDecoder(r) - if err := decoder.Decode(&feed); err != nil { - return nil, fmt.Errorf("Unable to decode feed modification JSON object: %v", err) - } - - return &feed, nil -} - func decodeEntryStatusRequest(r io.ReadCloser) ([]int64, string, error) { type payload struct { EntryIDs []int64 `json:"entry_ids"` diff --git a/api/payload_test.go b/api/payload_test.go deleted file mode 100644 index cb43b02d..00000000 --- a/api/payload_test.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2018 Frédéric Guillot. All rights reserved. -// Use of this source code is governed by the Apache 2.0 -// license that can be found in the LICENSE file. - -package api // import "miniflux.app/api" - -import ( - "testing" - - "miniflux.app/model" -) - -func TestUpdateFeedURL(t *testing.T) { - feedURL := "http://example.com/" - changes := &feedModificationRequest{FeedURL: &feedURL} - feed := &model.Feed{FeedURL: "http://example.org/"} - changes.Update(feed) - - if feed.FeedURL != feedURL { - t.Errorf(`Unexpected value, got %q instead of %q`, feed.FeedURL, feedURL) - } -} - -func TestUpdateFeedURLWithEmptyString(t *testing.T) { - feedURL := "" - changes := &feedModificationRequest{FeedURL: &feedURL} - feed := &model.Feed{FeedURL: "http://example.org/"} - changes.Update(feed) - - if feed.FeedURL == feedURL { - t.Error(`The FeedURL should not be modified`) - } -} - -func TestUpdateFeedURLWhenNotSet(t *testing.T) { - changes := &feedModificationRequest{} - feed := &model.Feed{FeedURL: "http://example.org/"} - changes.Update(feed) - - if feed.FeedURL != "http://example.org/" { - t.Error(`The FeedURL should not be modified`) - } -} - -func TestUpdateFeedSiteURL(t *testing.T) { - siteURL := "http://example.com/" - changes := &feedModificationRequest{SiteURL: &siteURL} - feed := &model.Feed{SiteURL: "http://example.org/"} - changes.Update(feed) - - if feed.SiteURL != siteURL { - t.Errorf(`Unexpected value, got %q instead of %q`, feed.SiteURL, siteURL) - } -} - -func TestUpdateFeedSiteURLWithEmptyString(t *testing.T) { - siteURL := "" - changes := &feedModificationRequest{FeedURL: &siteURL} - feed := &model.Feed{SiteURL: "http://example.org/"} - changes.Update(feed) - - if feed.SiteURL == siteURL { - t.Error(`The FeedURL should not be modified`) - } -} - -func TestUpdateFeedSiteURLWhenNotSet(t *testing.T) { - changes := &feedModificationRequest{} - feed := &model.Feed{SiteURL: "http://example.org/"} - changes.Update(feed) - - if feed.SiteURL != "http://example.org/" { - t.Error(`The SiteURL should not be modified`) - } -} - -func TestUpdateFeedTitle(t *testing.T) { - title := "Example 2" - changes := &feedModificationRequest{Title: &title} - feed := &model.Feed{Title: "Example"} - changes.Update(feed) - - if feed.Title != title { - t.Errorf(`Unexpected value, got %q instead of %q`, feed.Title, title) - } -} - -func TestUpdateFeedTitleWithEmptyString(t *testing.T) { - title := "" - changes := &feedModificationRequest{Title: &title} - feed := &model.Feed{Title: "Example"} - changes.Update(feed) - - if feed.Title == title { - t.Error(`The Title should not be modified`) - } -} - -func TestUpdateFeedTitleWhenNotSet(t *testing.T) { - changes := &feedModificationRequest{} - feed := &model.Feed{Title: "Example"} - changes.Update(feed) - - if feed.Title != "Example" { - t.Error(`The Title should not be modified`) - } -} - -func TestUpdateFeedUsername(t *testing.T) { - username := "Alice" - changes := &feedModificationRequest{Username: &username} - feed := &model.Feed{Username: "Bob"} - changes.Update(feed) - - if feed.Username != username { - t.Errorf(`Unexpected value, got %q instead of %q`, feed.Username, username) - } -} - -func TestUpdateFeedUsernameWithEmptyString(t *testing.T) { - username := "" - changes := &feedModificationRequest{Username: &username} - feed := &model.Feed{Username: "Bob"} - changes.Update(feed) - - if feed.Username != "" { - t.Error(`The Username should be empty now`) - } -} - -func TestUpdateFeedUsernameWhenNotSet(t *testing.T) { - changes := &feedModificationRequest{} - feed := &model.Feed{Username: "Alice"} - changes.Update(feed) - - if feed.Username != "Alice" { - t.Error(`The Username should not be modified`) - } -} - -func TestUpdateFeedDisabled(t *testing.T) { - valueTrue := true - valueFalse := false - scenarios := []struct { - changes *feedModificationRequest - feed *model.Feed - expected bool - }{ - {&feedModificationRequest{}, &model.Feed{Disabled: true}, true}, - {&feedModificationRequest{Disabled: &valueTrue}, &model.Feed{Disabled: true}, true}, - {&feedModificationRequest{Disabled: &valueFalse}, &model.Feed{Disabled: true}, false}, - {&feedModificationRequest{}, &model.Feed{Disabled: false}, false}, - {&feedModificationRequest{Disabled: &valueTrue}, &model.Feed{Disabled: false}, true}, - {&feedModificationRequest{Disabled: &valueFalse}, &model.Feed{Disabled: false}, false}, - } - - for _, scenario := range scenarios { - scenario.changes.Update(scenario.feed) - if scenario.feed.Disabled != scenario.expected { - t.Errorf(`Unexpected result, got %v, want: %v`, - scenario.feed.Disabled, - scenario.expected, - ) - } - } -} - -func TestUpdateFeedCategory(t *testing.T) { - categoryID := int64(1) - changes := &feedModificationRequest{CategoryID: &categoryID} - feed := &model.Feed{Category: &model.Category{ID: 42}} - changes.Update(feed) - - if feed.Category.ID != categoryID { - t.Errorf(`Unexpected value, got %q instead of %q`, feed.Username, categoryID) - } -} - -func TestUpdateFeedCategoryWithZero(t *testing.T) { - categoryID := int64(0) - changes := &feedModificationRequest{CategoryID: &categoryID} - feed := &model.Feed{Category: &model.Category{ID: 42}} - changes.Update(feed) - - if feed.Category.ID != 42 { - t.Error(`The CategoryID should not be modified`) - } -} - -func TestUpdateFeedCategoryWhenNotSet(t *testing.T) { - changes := &feedModificationRequest{} - feed := &model.Feed{Category: &model.Category{ID: 42}} - changes.Update(feed) - - if feed.Category.ID != 42 { - t.Error(`The CategoryID should not be modified`) - } -} - -func TestUpdateFeedToIgnoreCache(t *testing.T) { - value := true - changes := &feedModificationRequest{IgnoreHTTPCache: &value} - feed := &model.Feed{IgnoreHTTPCache: false} - changes.Update(feed) - - if feed.IgnoreHTTPCache != value { - t.Errorf(`The field IgnoreHTTPCache should be %v`, value) - } -} - -func TestUpdateFeedToFetchViaProxy(t *testing.T) { - value := true - changes := &feedModificationRequest{FetchViaProxy: &value} - feed := &model.Feed{FetchViaProxy: false} - changes.Update(feed) - - if feed.FetchViaProxy != value { - t.Errorf(`The field FetchViaProxy should be %v`, value) - } -} diff --git a/api/subscription.go b/api/subscription.go index 9a9a3e9d..514671fa 100644 --- a/api/subscription.go +++ b/api/subscription.go @@ -5,25 +5,33 @@ package api // import "miniflux.app/api" import ( + json_parser "encoding/json" "net/http" "miniflux.app/http/response/json" + "miniflux.app/model" "miniflux.app/reader/subscription" + "miniflux.app/validator" ) -func (h *handler) getSubscriptions(w http.ResponseWriter, r *http.Request) { - subscriptionRequest, bodyErr := decodeSubscriptionDiscoveryRequest(r.Body) - if bodyErr != nil { - json.BadRequest(w, r, bodyErr) +func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request) { + var subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest + if err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil { + json.BadRequest(w, r, err) + return + } + + if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil { + json.BadRequest(w, r, validationErr.Error()) return } subscriptions, finderErr := subscription.FindSubscriptions( - subscriptionRequest.URL, - subscriptionRequest.UserAgent, - subscriptionRequest.Username, - subscriptionRequest.Password, - subscriptionRequest.FetchViaProxy, + subscriptionDiscoveryRequest.URL, + subscriptionDiscoveryRequest.UserAgent, + subscriptionDiscoveryRequest.Username, + subscriptionDiscoveryRequest.Password, + subscriptionDiscoveryRequest.FetchViaProxy, ) if finderErr != nil { json.ServerError(w, r, finderErr) diff --git a/client/client.go b/client/client.go index 50c72e70..df34a0f8 100644 --- a/client/client.go +++ b/client/client.go @@ -314,8 +314,7 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) defer body.Close() var f *Feed - decoder := json.NewDecoder(body) - if err := decoder.Decode(&f); err != nil { + if err := json.NewDecoder(body).Decode(&f); err != nil { return nil, fmt.Errorf("miniflux: response error (%v)", err) } diff --git a/locale/translations.go b/locale/translations.go index 0e73dfa1..94316a23 100644 --- a/locale/translations.go +++ b/locale/translations.go @@ -241,6 +241,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.", "error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.", "error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.", + "error.feed_already_exists": "Dieser Feed existiert bereits.", + "error.invalid_feed_url": "Ungültige Feed-URL.", + "error.invalid_site_url": "Ungültige Site-URL.", + "error.feed_url_not_empty": "Die Feed-URL darf nicht leer sein.", + "error.site_url_not_empty": "Die Site-URL darf nicht leer sein.", + "error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.", + "error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.", "error.user_mandatory_fields": "Der Benutzername ist obligatorisch.", "error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.", "error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.", @@ -599,6 +606,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.", "error.entries_per_page_invalid": "The number of entries per page is not valid.", "error.feed_mandatory_fields": "The URL and the category are mandatory.", + "error.feed_already_exists": "This feed already exists.", + "error.invalid_feed_url": "Invalid feed URL.", + "error.invalid_site_url": "Invalid site URL.", + "error.feed_url_not_empty": "The feed URL cannot be empty.", + "error.site_url_not_empty": "The site URL cannot be empty.", + "error.feed_title_not_empty": "The feed title cannot be empty.", + "error.feed_category_not_found": "This category does not exist or does not belong to this user.", "error.user_mandatory_fields": "The username is mandatory.", "error.api_key_already_exists": "This API Key already exists.", "error.unable_to_create_api_key": "Unable to create this API Key.", @@ -929,6 +943,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.", "error.entries_per_page_invalid": "El número de entradas por página no es válido.", "error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.", + "error.feed_already_exists": "Este feed ya existe.", + "error.invalid_feed_url": "URL de feed no válida.", + "error.invalid_site_url": "URL del sitio no válida.", + "error.feed_url_not_empty": "La URL del feed no puede estar vacía.", + "error.site_url_not_empty": "La URL del sitio no puede estar vacía.", + "error.feed_title_not_empty": "El título del feed no puede estar vacío.", + "error.feed_category_not_found": "Esta categoría no existe o no pertenece a este usuario.", "error.user_mandatory_fields": "El nombre de usuario es obligatorio.", "error.api_key_already_exists": "Esta clave API ya existe.", "error.unable_to_create_api_key": "No se puede crear esta clave API.", @@ -1263,6 +1284,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.", "error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.", "error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.", + "error.feed_already_exists": "Ce flux existe déjà.", + "error.invalid_feed_url": "URL de flux non valide.", + "error.invalid_site_url": "URL de site non valide.", + "error.feed_url_not_empty": "L'URL du flux ne peut pas être vide.", + "error.site_url_not_empty": "L'URL du site ne peut pas être vide.", + "error.feed_title_not_empty": "Le titre du flux ne peut pas être vide.", + "error.feed_category_not_found": "Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.", "error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.", "error.api_key_already_exists": "Cette clé d'API existe déjà.", "error.unable_to_create_api_key": "Impossible de créer cette clé d'API.", @@ -1617,6 +1645,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.", "error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.", "error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.", + "error.feed_already_exists": "Questo feed esiste già.", + "error.invalid_feed_url": "URL del feed non valido.", + "error.invalid_site_url": "URL del sito non valido.", + "error.feed_url_not_empty": "L'URL del feed non può essere vuoto.", + "error.site_url_not_empty": "L'URL del sito non può essere vuoto.", + "error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.", + "error.feed_category_not_found": "Questa categoria non esiste o non appartiene a questo utente.", "error.user_mandatory_fields": "Il nome utente è obbligatorio.", "error.api_key_already_exists": "Questa chiave API esiste già.", "error.unable_to_create_api_key": "Impossibile creare questa chiave API.", @@ -1951,6 +1986,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。", "error.entries_per_page_invalid": "ページあたりのエントリ数が無効です。", "error.feed_mandatory_fields": "URL と カテゴリが必要です。", + "error.feed_already_exists": "このフィードはすでに存在します。", + "error.invalid_feed_url": "無効なフィードURL。", + "error.invalid_site_url": "無効なサイトURL。", + "error.feed_url_not_empty": "フィードURLを空にすることはできません。", + "error.site_url_not_empty": "サイトのURLを空にすることはできません。", + "error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。", + "error.feed_category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。", "error.user_mandatory_fields": "ユーザー名が必要です。", "error.api_key_already_exists": "このAPIキーは既に存在します。", "error.unable_to_create_api_key": "このAPIキーを作成できません。", @@ -2285,6 +2327,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.", "error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.", "error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.", + "error.feed_already_exists": "Deze feed bestaat al.", + "error.invalid_feed_url": "Ongeldige feed-URL.", + "error.invalid_site_url": "Ongeldige site-URL.", + "error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.", + "error.site_url_not_empty": "De site-URL mag niet leeg zijn.", + "error.feed_title_not_empty": "De feedtitel mag niet leeg zijn.", + "error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.", "error.user_mandatory_fields": "Gebruikersnaam is verplicht", "error.api_key_already_exists": "This API Key already exists.", "error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.", @@ -2639,6 +2688,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.", "error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.", "error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.", + "error.feed_already_exists": "Ten kanał już istnieje.", + "error.invalid_feed_url": "Nieprawidłowy adres URL kanału.", + "error.invalid_site_url": "Nieprawidłowy adres URL witryny.", + "error.feed_url_not_empty": "Adres URL kanału nie może być pusty.", + "error.site_url_not_empty": "Adres URL witryny nie może być pusty.", + "error.feed_title_not_empty": "Tytuł kanału nie może być pusty.", + "error.feed_category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.", "error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.", "error.api_key_already_exists": "Deze API-sleutel bestaat al.", "error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.", @@ -2997,6 +3053,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.", "error.entries_per_page_invalid": "O número de itens por página é inválido.", "error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.", + "error.feed_already_exists": "Este feed já existe.", + "error.invalid_feed_url": "URL de feed inválido.", + "error.invalid_site_url": "URL de site inválido.", + "error.feed_url_not_empty": "O URL do feed não pode estar vazio.", + "error.site_url_not_empty": "O URL do site não pode estar vazio.", + "error.feed_title_not_empty": "O título do feed não pode estar vazio.", + "error.feed_category_not_found": "Esta categoria não existe ou não pertence a este usuário.", "error.user_mandatory_fields": "O nome de usuário é obrigatório.", "error.api_key_already_exists": "Essa chave de API já existe.", "error.unable_to_create_api_key": "Não foi possível criar uma chave de API.", @@ -3333,6 +3396,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.", "error.entries_per_page_invalid": "Количество записей на странице недействительно.", "error.feed_mandatory_fields": "URL и категория обязательны.", + "error.feed_already_exists": "Этот фид уже существует.", + "error.invalid_feed_url": "Недействительный URL фида.", + "error.invalid_site_url": "Недействительный URL сайта.", + "error.feed_url_not_empty": "URL-адрес канала не может быть пустым.", + "error.site_url_not_empty": "URL сайта не может быть пустым.", + "error.feed_title_not_empty": "Заголовок фида не может быть пустым.", + "error.feed_category_not_found": "Эта категория не существует или не принадлежит этому пользователю.", "error.user_mandatory_fields": "Имя пользователя обязательно.", "error.api_key_already_exists": "Этот ключ API уже существует.", "error.unable_to_create_api_key": "Невозможно создать этот ключ API.", @@ -3671,6 +3741,13 @@ var translations = map[string]string{ "error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区", "error.entries_per_page_invalid": "每页的条目数无效。", "error.feed_mandatory_fields": "必须填写 URL 和分类", + "error.feed_already_exists": "此供稿已存在。", + "error.invalid_feed_url": "供稿网址无效。", + "error.invalid_site_url": "无效的网站网址。", + "error.feed_url_not_empty": "供稿网址不能为空。", + "error.site_url_not_empty": "网站网址不能为空。", + "error.feed_title_not_empty": "供稿标题不能为空。", + "error.feed_category_not_found": "此类别不存在或不属于该用户。", "error.user_mandatory_fields": "必须填写用户名", "error.api_key_already_exists": "此API密钥已存在。", "error.unable_to_create_api_key": "无法创建此API密钥。", @@ -3783,15 +3860,15 @@ var translations = map[string]string{ } var translationsChecksums = map[string]string{ - "de_DE": "c8d6021599cfda4f853bd5ec1e1b065f03633ada9211ee22879ea778ba464572", - "en_US": "781a7a6b54f439d76fe56fca7cb07412a04e71edebf53563f5cca27a0cd2533a", - "es_ES": "4d602461f5ed9c4aaf59e8828d2b09d0cc45d06ba77d89ba0ef9662b580aebc0", - "fr_FR": "3a0a008d0857fa5eb8a018ce5e348d7ccabe08a67849c72c6e7611e6b5b49aa7", - "it_IT": "7222e3610ad3741aa7aff957f70524b63ffe3f6729198899231765335861a108", - "ja_JP": "f0ab6dd77c78717d25d88baad39c487c913720be4b3473a3f0aa3aa538318deb", - "nl_NL": "1e0872b89fb78a6de2ae989d054963226146c3eeff4b2883cf2bf8df96c13846", - "pl_PL": "2513808a13925549c9ba27c52a20916d18a5222dd8ba6a14520798766889b076", - "pt_BR": "b5fc4d9e0dedc554579154f2fff772b108baf317c9a952d688db0df260674b3b", - "ru_RU": "d9bedead0757deae57da909c7d5297853c2186acb8ebf7cf91d0eef7c1a17d19", - "zh_CN": "2526d0139ca0a2004f2db0864cbc9c3da55c3c7f45e1a244fea3c39d5d39e0f9", + "de_DE": "9db01e4337375c37edae008d93ccf8f1ab50f6b30a468cefe75257e5b1364513", + "en_US": "506dc83a66e38147328f924b15f1280a71fb3cc5f83c1f690c6029d137f8baee", + "es_ES": "37c7d271dcae76f524a8e52b86bfaa9dc680954ba75ed53e7945b95ffe2ae9e9", + "fr_FR": "ed626b9752239c0f89a17ff28c5003a5ab930a3f0d1df5628b23e8de3587c0f5", + "it_IT": "015892b01bc85407a0813eb60deb1a1bbbfaf2b72bb9194e13378083f6f54b84", + "ja_JP": "ea1843af4638ce58cfe4ca730133e7ef178c6242b6bd253c714b179b45efde9f", + "nl_NL": "fe3d9e519d3326d0ff51590011ac6cb344e26e0aa241a8295fb38ca7a7c2191c", + "pl_PL": "5af4497ab4420ff8cec45b86dc65ddc685cd9cca0fb750e238d57f1a8d43c32f", + "pt_BR": "052cfe35211165ed7de9e99109a819d362b5a69b490bb58cc6d884e8fbbf4469", + "ru_RU": "c7216ede62f1c1d18b2ad05bb20a2dfdab04e7bb968773a705da8c26cd5bdcd8", + "zh_CN": "8e02550d068e3d8020bd7923a00e5a045dd09db1cc0dfdaa2294417175068743", } diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json index b2814e2e..b95f264a 100644 --- a/locale/translations/de_DE.json +++ b/locale/translations/de_DE.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.", "error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.", "error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.", + "error.feed_already_exists": "Dieser Feed existiert bereits.", + "error.invalid_feed_url": "Ungültige Feed-URL.", + "error.invalid_site_url": "Ungültige Site-URL.", + "error.feed_url_not_empty": "Die Feed-URL darf nicht leer sein.", + "error.site_url_not_empty": "Die Site-URL darf nicht leer sein.", + "error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.", + "error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.", "error.user_mandatory_fields": "Der Benutzername ist obligatorisch.", "error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.", "error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.", diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json index 0f0f578b..8df2b40c 100644 --- a/locale/translations/en_US.json +++ b/locale/translations/en_US.json @@ -240,6 +240,13 @@ "error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.", "error.entries_per_page_invalid": "The number of entries per page is not valid.", "error.feed_mandatory_fields": "The URL and the category are mandatory.", + "error.feed_already_exists": "This feed already exists.", + "error.invalid_feed_url": "Invalid feed URL.", + "error.invalid_site_url": "Invalid site URL.", + "error.feed_url_not_empty": "The feed URL cannot be empty.", + "error.site_url_not_empty": "The site URL cannot be empty.", + "error.feed_title_not_empty": "The feed title cannot be empty.", + "error.feed_category_not_found": "This category does not exist or does not belong to this user.", "error.user_mandatory_fields": "The username is mandatory.", "error.api_key_already_exists": "This API Key already exists.", "error.unable_to_create_api_key": "Unable to create this API Key.", diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json index d36af225..11eb6f15 100644 --- a/locale/translations/es_ES.json +++ b/locale/translations/es_ES.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.", "error.entries_per_page_invalid": "El número de entradas por página no es válido.", "error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.", + "error.feed_already_exists": "Este feed ya existe.", + "error.invalid_feed_url": "URL de feed no válida.", + "error.invalid_site_url": "URL del sitio no válida.", + "error.feed_url_not_empty": "La URL del feed no puede estar vacía.", + "error.site_url_not_empty": "La URL del sitio no puede estar vacía.", + "error.feed_title_not_empty": "El título del feed no puede estar vacío.", + "error.feed_category_not_found": "Esta categoría no existe o no pertenece a este usuario.", "error.user_mandatory_fields": "El nombre de usuario es obligatorio.", "error.api_key_already_exists": "Esta clave API ya existe.", "error.unable_to_create_api_key": "No se puede crear esta clave API.", diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index da0e50fd..b240b6a9 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.", "error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.", "error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.", + "error.feed_already_exists": "Ce flux existe déjà.", + "error.invalid_feed_url": "URL de flux non valide.", + "error.invalid_site_url": "URL de site non valide.", + "error.feed_url_not_empty": "L'URL du flux ne peut pas être vide.", + "error.site_url_not_empty": "L'URL du site ne peut pas être vide.", + "error.feed_title_not_empty": "Le titre du flux ne peut pas être vide.", + "error.feed_category_not_found": "Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.", "error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.", "error.api_key_already_exists": "Cette clé d'API existe déjà.", "error.unable_to_create_api_key": "Impossible de créer cette clé d'API.", diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json index da5a1fac..113d78c0 100644 --- a/locale/translations/it_IT.json +++ b/locale/translations/it_IT.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.", "error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.", "error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.", + "error.feed_already_exists": "Questo feed esiste già.", + "error.invalid_feed_url": "URL del feed non valido.", + "error.invalid_site_url": "URL del sito non valido.", + "error.feed_url_not_empty": "L'URL del feed non può essere vuoto.", + "error.site_url_not_empty": "L'URL del sito non può essere vuoto.", + "error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.", + "error.feed_category_not_found": "Questa categoria non esiste o non appartiene a questo utente.", "error.user_mandatory_fields": "Il nome utente è obbligatorio.", "error.api_key_already_exists": "Questa chiave API esiste già.", "error.unable_to_create_api_key": "Impossibile creare questa chiave API.", diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json index 0253b5e4..feebc55f 100644 --- a/locale/translations/ja_JP.json +++ b/locale/translations/ja_JP.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。", "error.entries_per_page_invalid": "ページあたりのエントリ数が無効です。", "error.feed_mandatory_fields": "URL と カテゴリが必要です。", + "error.feed_already_exists": "このフィードはすでに存在します。", + "error.invalid_feed_url": "無効なフィードURL。", + "error.invalid_site_url": "無効なサイトURL。", + "error.feed_url_not_empty": "フィードURLを空にすることはできません。", + "error.site_url_not_empty": "サイトのURLを空にすることはできません。", + "error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。", + "error.feed_category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。", "error.user_mandatory_fields": "ユーザー名が必要です。", "error.api_key_already_exists": "このAPIキーは既に存在します。", "error.unable_to_create_api_key": "このAPIキーを作成できません。", diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json index a84248f0..0b8bf818 100644 --- a/locale/translations/nl_NL.json +++ b/locale/translations/nl_NL.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.", "error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.", "error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.", + "error.feed_already_exists": "Deze feed bestaat al.", + "error.invalid_feed_url": "Ongeldige feed-URL.", + "error.invalid_site_url": "Ongeldige site-URL.", + "error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.", + "error.site_url_not_empty": "De site-URL mag niet leeg zijn.", + "error.feed_title_not_empty": "De feedtitel mag niet leeg zijn.", + "error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.", "error.user_mandatory_fields": "Gebruikersnaam is verplicht", "error.api_key_already_exists": "This API Key already exists.", "error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.", diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json index acfafcc7..319a0804 100644 --- a/locale/translations/pl_PL.json +++ b/locale/translations/pl_PL.json @@ -238,6 +238,13 @@ "error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.", "error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.", "error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.", + "error.feed_already_exists": "Ten kanał już istnieje.", + "error.invalid_feed_url": "Nieprawidłowy adres URL kanału.", + "error.invalid_site_url": "Nieprawidłowy adres URL witryny.", + "error.feed_url_not_empty": "Adres URL kanału nie może być pusty.", + "error.site_url_not_empty": "Adres URL witryny nie może być pusty.", + "error.feed_title_not_empty": "Tytuł kanału nie może być pusty.", + "error.feed_category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.", "error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.", "error.api_key_already_exists": "Deze API-sleutel bestaat al.", "error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.", diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json index d864bc59..266a444d 100644 --- a/locale/translations/pt_BR.json +++ b/locale/translations/pt_BR.json @@ -236,6 +236,13 @@ "error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.", "error.entries_per_page_invalid": "O número de itens por página é inválido.", "error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.", + "error.feed_already_exists": "Este feed já existe.", + "error.invalid_feed_url": "URL de feed inválido.", + "error.invalid_site_url": "URL de site inválido.", + "error.feed_url_not_empty": "O URL do feed não pode estar vazio.", + "error.site_url_not_empty": "O URL do site não pode estar vazio.", + "error.feed_title_not_empty": "O título do feed não pode estar vazio.", + "error.feed_category_not_found": "Esta categoria não existe ou não pertence a este usuário.", "error.user_mandatory_fields": "O nome de usuário é obrigatório.", "error.api_key_already_exists": "Essa chave de API já existe.", "error.unable_to_create_api_key": "Não foi possível criar uma chave de API.", diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json index 9579cf17..715f36bc 100644 --- a/locale/translations/ru_RU.json +++ b/locale/translations/ru_RU.json @@ -238,6 +238,13 @@ "error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.", "error.entries_per_page_invalid": "Количество записей на странице недействительно.", "error.feed_mandatory_fields": "URL и категория обязательны.", + "error.feed_already_exists": "Этот фид уже существует.", + "error.invalid_feed_url": "Недействительный URL фида.", + "error.invalid_site_url": "Недействительный URL сайта.", + "error.feed_url_not_empty": "URL-адрес канала не может быть пустым.", + "error.site_url_not_empty": "URL сайта не может быть пустым.", + "error.feed_title_not_empty": "Заголовок фида не может быть пустым.", + "error.feed_category_not_found": "Эта категория не существует или не принадлежит этому пользователю.", "error.user_mandatory_fields": "Имя пользователя обязательно.", "error.api_key_already_exists": "Этот ключ API уже существует.", "error.unable_to_create_api_key": "Невозможно создать этот ключ API.", diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json index 30b4916b..9de8c1b3 100644 --- a/locale/translations/zh_CN.json +++ b/locale/translations/zh_CN.json @@ -234,6 +234,13 @@ "error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区", "error.entries_per_page_invalid": "每页的条目数无效。", "error.feed_mandatory_fields": "必须填写 URL 和分类", + "error.feed_already_exists": "此供稿已存在。", + "error.invalid_feed_url": "供稿网址无效。", + "error.invalid_site_url": "无效的网站网址。", + "error.feed_url_not_empty": "供稿网址不能为空。", + "error.site_url_not_empty": "网站网址不能为空。", + "error.feed_title_not_empty": "供稿标题不能为空。", + "error.feed_category_not_found": "此类别不存在或不属于该用户。", "error.user_mandatory_fields": "必须填写用户名", "error.api_key_already_exists": "此API密钥已存在。", "error.unable_to_create_api_key": "无法创建此API密钥。", diff --git a/model/feed.go b/model/feed.go index c74138e1..020351e9 100644 --- a/model/feed.go +++ b/model/feed.go @@ -13,6 +13,12 @@ import ( "miniflux.app/http/client" ) +// List of supported schedulers. +const ( + SchedulerRoundRobin = "round_robin" + SchedulerEntryFrequency = "entry_frequency" +) + // Feed represents a feed in the application. type Feed struct { ID int64 `json:"id"` @@ -44,12 +50,6 @@ type Feed struct { ReadCount int `json:"-"` } -// List of supported schedulers. -const ( - SchedulerRoundRobin = "round_robin" - SchedulerEntryFrequency = "entry_frequency" -) - func (f *Feed) String() string { return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}", f.ID, @@ -112,5 +112,104 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int) { } } +// FeedCreationRequest represents the request to create a feed. +type FeedCreationRequest struct { + FeedURL string `json:"feed_url"` + CategoryID int64 `json:"category_id"` + UserAgent string `json:"user_agent"` + Username string `json:"username"` + Password string `json:"password"` + Crawler bool `json:"crawler"` + Disabled bool `json:"disabled"` + IgnoreHTTPCache bool `json:"ignore_http_cache"` + FetchViaProxy bool `json:"fetch_via_proxy"` + ScraperRules string `json:"scraper_rules"` + RewriteRules string `json:"rewrite_rules"` + BlocklistRules string `json:"blocklist_rules"` + KeeplistRules string `json:"keeplist_rules"` +} + +// FeedModificationRequest represents the request to update a feed. +type FeedModificationRequest struct { + FeedURL *string `json:"feed_url"` + SiteURL *string `json:"site_url"` + Title *string `json:"title"` + ScraperRules *string `json:"scraper_rules"` + RewriteRules *string `json:"rewrite_rules"` + BlocklistRules *string `json:"blocklist_rules"` + KeeplistRules *string `json:"keeplist_rules"` + Crawler *bool `json:"crawler"` + UserAgent *string `json:"user_agent"` + Username *string `json:"username"` + Password *string `json:"password"` + CategoryID *int64 `json:"category_id"` + Disabled *bool `json:"disabled"` + IgnoreHTTPCache *bool `json:"ignore_http_cache"` + FetchViaProxy *bool `json:"fetch_via_proxy"` +} + +// Patch updates a feed with modified values. +func (f *FeedModificationRequest) Patch(feed *Feed) { + if f.FeedURL != nil && *f.FeedURL != "" { + feed.FeedURL = *f.FeedURL + } + + if f.SiteURL != nil && *f.SiteURL != "" { + feed.SiteURL = *f.SiteURL + } + + if f.Title != nil && *f.Title != "" { + feed.Title = *f.Title + } + + if f.ScraperRules != nil { + feed.ScraperRules = *f.ScraperRules + } + + if f.RewriteRules != nil { + feed.RewriteRules = *f.RewriteRules + } + + if f.KeeplistRules != nil { + feed.KeeplistRules = *f.KeeplistRules + } + + if f.BlocklistRules != nil { + feed.BlocklistRules = *f.BlocklistRules + } + + if f.Crawler != nil { + feed.Crawler = *f.Crawler + } + + if f.UserAgent != nil { + feed.UserAgent = *f.UserAgent + } + + if f.Username != nil { + feed.Username = *f.Username + } + + if f.Password != nil { + feed.Password = *f.Password + } + + if f.CategoryID != nil && *f.CategoryID > 0 { + feed.Category.ID = *f.CategoryID + } + + if f.Disabled != nil { + feed.Disabled = *f.Disabled + } + + if f.IgnoreHTTPCache != nil { + feed.IgnoreHTTPCache = *f.IgnoreHTTPCache + } + + if f.FetchViaProxy != nil { + feed.FetchViaProxy = *f.FetchViaProxy + } +} + // Feeds is a list of feed type Feeds []*Feed diff --git a/model/model.go b/model/model.go index 5f9a5708..74b72b34 100644 --- a/model/model.go +++ b/model/model.go @@ -19,3 +19,11 @@ func OptionalInt(value int) *int { } return nil } + +// OptionalInt64 populates an optional int64 field. +func OptionalInt64(value int64) *int64 { + if value > 0 { + return &value + } + return nil +} diff --git a/model/subscription.go b/model/subscription.go new file mode 100644 index 00000000..f866e891 --- /dev/null +++ b/model/subscription.go @@ -0,0 +1,14 @@ +// Copyright 2020 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package model // import "miniflux.app/model" + +// SubscriptionDiscoveryRequest represents a request to discover subscriptions. +type SubscriptionDiscoveryRequest struct { + URL string `json:"url"` + UserAgent string `json:"user_agent"` + Username string `json:"username"` + Password string `json:"password"` + FetchViaProxy bool `json:"fetch_via_proxy"` +} diff --git a/reader/handler/handler.go b/reader/handler/handler.go index 37e1dd50..ddaaa7bd 100644 --- a/reader/handler/handler.go +++ b/reader/handler/handler.go @@ -28,37 +28,19 @@ var ( errCategoryNotFound = "Category not found for this user" ) -// FeedCreationArgs represents the arguments required to create a new feed. -type FeedCreationArgs struct { - UserID int64 - CategoryID int64 - FeedURL string - UserAgent string - Username string - Password string - Crawler bool - Disabled bool - IgnoreHTTPCache bool - FetchViaProxy bool - ScraperRules string - RewriteRules string - BlocklistRules string - KeeplistRules string -} - // CreateFeed fetch, parse and store a new feed. -func CreateFeed(store *storage.Storage, args *FeedCreationArgs) (*model.Feed, error) { - defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[CreateFeed] FeedURL=%s", args.FeedURL)) +func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequest) (*model.Feed, error) { + defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[CreateFeed] FeedURL=%s", feedCreationRequest.FeedURL)) - if !store.CategoryIDExists(args.UserID, args.CategoryID) { + if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) { return nil, errors.NewLocalizedError(errCategoryNotFound) } - request := client.NewClientWithConfig(args.FeedURL, config.Opts) - request.WithCredentials(args.Username, args.Password) - request.WithUserAgent(args.UserAgent) + request := client.NewClientWithConfig(feedCreationRequest.FeedURL, config.Opts) + request.WithCredentials(feedCreationRequest.Username, feedCreationRequest.Password) + request.WithUserAgent(feedCreationRequest.UserAgent) - if args.FetchViaProxy { + if feedCreationRequest.FetchViaProxy { request.WithProxy() } @@ -67,7 +49,7 @@ func CreateFeed(store *storage.Storage, args *FeedCreationArgs) (*model.Feed, er return nil, requestErr } - if store.FeedURLExists(args.UserID, response.EffectiveURL) { + if store.FeedURLExists(userID, response.EffectiveURL) { return nil, errors.NewLocalizedError(errDuplicate, response.EffectiveURL) } @@ -76,19 +58,19 @@ func CreateFeed(store *storage.Storage, args *FeedCreationArgs) (*model.Feed, er return nil, parseErr } - subscription.UserID = args.UserID - subscription.UserAgent = args.UserAgent - subscription.Username = args.Username - subscription.Password = args.Password - subscription.Crawler = args.Crawler - subscription.Disabled = args.Disabled - subscription.IgnoreHTTPCache = args.IgnoreHTTPCache - subscription.FetchViaProxy = args.FetchViaProxy - subscription.ScraperRules = args.ScraperRules - subscription.RewriteRules = args.RewriteRules - subscription.BlocklistRules = args.BlocklistRules - subscription.KeeplistRules = args.KeeplistRules - subscription.WithCategoryID(args.CategoryID) + subscription.UserID = userID + subscription.UserAgent = feedCreationRequest.UserAgent + subscription.Username = feedCreationRequest.Username + subscription.Password = feedCreationRequest.Password + subscription.Crawler = feedCreationRequest.Crawler + subscription.Disabled = feedCreationRequest.Disabled + subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache + subscription.FetchViaProxy = feedCreationRequest.FetchViaProxy + subscription.ScraperRules = feedCreationRequest.ScraperRules + subscription.RewriteRules = feedCreationRequest.RewriteRules + subscription.BlocklistRules = feedCreationRequest.BlocklistRules + subscription.KeeplistRules = feedCreationRequest.KeeplistRules + subscription.WithCategoryID(feedCreationRequest.CategoryID) subscription.WithClientResponse(response) subscription.CheckedNow() @@ -100,7 +82,7 @@ func CreateFeed(store *storage.Storage, args *FeedCreationArgs) (*model.Feed, er logger.Debug("[CreateFeed] Feed saved with ID: %d", subscription.ID) - checkFeedIcon(store, subscription.ID, subscription.SiteURL, args.FetchViaProxy) + checkFeedIcon(store, subscription.ID, subscription.SiteURL, feedCreationRequest.FetchViaProxy) return subscription, nil } diff --git a/tests/feed_test.go b/tests/feed_test.go index a3bc35a2..b01c346c 100644 --- a/tests/feed_test.go +++ b/tests/feed_test.go @@ -46,6 +46,38 @@ func TestCreateFeedWithInexistingCategory(t *testing.T) { } } +func TestCreateFeedWithEmptyFeedURL(t *testing.T) { + client := createClient(t) + categories, err := client.Categories() + if err != nil { + t.Fatal(err) + } + + _, err = client.CreateFeed(&miniflux.FeedCreationRequest{ + FeedURL: "", + CategoryID: categories[0].ID, + }) + if err == nil { + t.Fatal(`Feeds should not be created with an empty feed URL`) + } +} + +func TestCreateFeedWithInvalidFeedURL(t *testing.T) { + client := createClient(t) + categories, err := client.Categories() + if err != nil { + t.Fatal(err) + } + + _, err = client.CreateFeed(&miniflux.FeedCreationRequest{ + FeedURL: "invalid", + CategoryID: categories[0].ID, + }) + if err == nil { + t.Fatal(`Feeds should not be created with an invalid feed URL`) + } +} + func TestCreateDisabledFeed(t *testing.T) { client := createClient(t) @@ -174,7 +206,7 @@ func TestUpdateFeedURL(t *testing.T) { client := createClient(t) feed, _ := createFeed(t, client) - url := "test" + url := "https://www.example.org/feed.xml" updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{FeedURL: &url}) if err != nil { t.Fatal(err) @@ -183,15 +215,25 @@ func TestUpdateFeedURL(t *testing.T) { if updatedFeed.FeedURL != url { t.Fatalf(`Wrong FeedURL, got %q instead of %q`, updatedFeed.FeedURL, url) } +} - url = "" - updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{FeedURL: &url}) - if err != nil { - t.Fatal(err) +func TestUpdateFeedWithEmptyFeedURL(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) + + url := "" + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{FeedURL: &url}); err == nil { + t.Error(`Updating a feed with an empty feed URL should not be possible`) } +} - if updatedFeed.FeedURL == "" { - t.Fatalf(`The FeedURL should not be empty`) +func TestUpdateFeedWithInvalidFeedURL(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) + + url := "invalid" + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{FeedURL: &url}); err == nil { + t.Error(`Updating a feed with an invalid feed URL should not be possible`) } } @@ -199,7 +241,7 @@ func TestUpdateFeedSiteURL(t *testing.T) { client := createClient(t) feed, _ := createFeed(t, client) - url := "test" + url := "https://www.example.org/" updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{SiteURL: &url}) if err != nil { t.Fatal(err) @@ -208,15 +250,25 @@ func TestUpdateFeedSiteURL(t *testing.T) { if updatedFeed.SiteURL != url { t.Fatalf(`Wrong SiteURL, got %q instead of %q`, updatedFeed.SiteURL, url) } +} - url = "" - updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{SiteURL: &url}) - if err != nil { - t.Fatal(err) +func TestUpdateFeedWithEmptySiteURL(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) + + url := "" + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{SiteURL: &url}); err == nil { + t.Error(`Updating a feed with an empty site URL should not be possible`) } +} - if updatedFeed.SiteURL == "" { - t.Fatalf(`The SiteURL should not be empty`) +func TestUpdateFeedWithInvalidSiteURL(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) + + url := "invalid" + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{SiteURL: &url}); err == nil { + t.Error(`Updating a feed with an invalid site URL should not be possible`) } } @@ -233,15 +285,15 @@ func TestUpdateFeedTitle(t *testing.T) { if updatedFeed.Title != newTitle { t.Fatalf(`Wrong title, got %q instead of %q`, updatedFeed.Title, newTitle) } +} - newTitle = "" - updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{Title: &newTitle}) - if err != nil { - t.Fatal(err) - } +func TestUpdateFeedWithEmptyTitle(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) - if updatedFeed.Title == "" { - t.Fatalf(`The Title should not be empty`) + title := "" + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{Title: &title}); err == nil { + t.Error(`Updating a feed with an empty title should not be possible`) } } @@ -441,15 +493,25 @@ func TestUpdateFeedCategory(t *testing.T) { if updatedFeed.Category.ID != newCategory.ID { t.Fatalf(`Wrong CategoryID value, got "%v" instead of "%v"`, updatedFeed.Category.ID, newCategory.ID) } +} + +func TestUpdateFeedWithEmptyCategoryID(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) categoryID := int64(0) - updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{CategoryID: &categoryID}) - if err != nil { - t.Fatal(err) + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{CategoryID: &categoryID}); err == nil { + t.Error(`Updating a feed with an empty category should not be possible`) } +} - if updatedFeed.Category.ID == 0 { - t.Fatalf(`The CategoryID must defined`) +func TestUpdateFeedWithInvalidCategoryID(t *testing.T) { + client := createClient(t) + feed, _ := createFeed(t, client) + + categoryID := int64(-1) + if _, err := client.UpdateFeed(feed.ID, &miniflux.FeedModificationRequest{CategoryID: &categoryID}); err == nil { + t.Error(`Updating a feed with an invalid category should not be possible`) } } diff --git a/tests/subscription_test.go b/tests/subscription_test.go index dd4d3d58..4d541f44 100644 --- a/tests/subscription_test.go +++ b/tests/subscription_test.go @@ -8,6 +8,8 @@ package tests import ( "testing" + + miniflux "miniflux.app/client" ) func TestDiscoverSubscriptions(t *testing.T) { @@ -33,3 +35,19 @@ func TestDiscoverSubscriptions(t *testing.T) { t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, subscriptions[0].URL, testFeedURL) } } + +func TestDiscoverSubscriptionsWithInvalidURL(t *testing.T) { + client := createClient(t) + _, err := client.Discover("invalid") + if err == nil { + t.Fatal(`Invalid URLs should be rejected`) + } +} + +func TestDiscoverSubscriptionsWithNoSubscription(t *testing.T) { + client := createClient(t) + _, err := client.Discover(testBaseURL) + if err != miniflux.ErrNotFound { + t.Fatal(`A 404 should be returned when there is no subscription`) + } +} diff --git a/ui/feed_update.go b/ui/feed_update.go index a4c33caf..fb5aeee9 100644 --- a/ui/feed_update.go +++ b/ui/feed_update.go @@ -12,20 +12,22 @@ import ( "miniflux.app/http/response/html" "miniflux.app/http/route" "miniflux.app/logger" + "miniflux.app/model" "miniflux.app/ui/form" "miniflux.app/ui/session" "miniflux.app/ui/view" + "miniflux.app/validator" ) func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { - user, err := h.store.UserByID(request.UserID(r)) + loggedUser, err := h.store.UserByID(request.UserID(r)) if err != nil { html.ServerError(w, r, err) return } feedID := request.RouteInt64Param(r, "feedID") - feed, err := h.store.FeedByID(user.ID, feedID) + feed, err := h.store.FeedByID(loggedUser.ID, feedID) if err != nil { html.ServerError(w, r, err) return @@ -36,7 +38,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { return } - categories, err := h.store.Categories(user.ID) + categories, err := h.store.Categories(loggedUser.ID) if err != nil { html.ServerError(w, r, err) return @@ -50,13 +52,20 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { view.Set("categories", categories) view.Set("feed", feed) view.Set("menu", "feeds") - view.Set("user", user) - view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) - view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) + view.Set("user", loggedUser) + view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID)) + view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID)) view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent()) - if err := feedForm.ValidateModification(); err != nil { - view.Set("errorMessage", err.Error()) + feedModificationRequest := &model.FeedModificationRequest{ + FeedURL: model.OptionalString(feedForm.FeedURL), + SiteURL: model.OptionalString(feedForm.SiteURL), + Title: model.OptionalString(feedForm.Title), + CategoryID: model.OptionalInt64(feedForm.CategoryID), + } + + if validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feedModificationRequest); validationErr != nil { + view.Set("errorMessage", validationErr.TranslationKey) html.OK(w, r, view.Render("edit_feed")) return } diff --git a/ui/form/feed.go b/ui/form/feed.go index 23db7952..5623f3b4 100644 --- a/ui/form/feed.go +++ b/ui/form/feed.go @@ -8,7 +8,6 @@ import ( "net/http" "strconv" - "miniflux.app/errors" "miniflux.app/model" ) @@ -31,14 +30,6 @@ type FeedForm struct { Disabled bool } -// ValidateModification validates FeedForm fields -func (f FeedForm) ValidateModification() error { - if f.FeedURL == "" || f.SiteURL == "" || f.Title == "" || f.CategoryID == 0 { - return errors.NewLocalizedError("error.fields_mandatory") - } - return nil -} - // Merge updates the fields of the given feed. func (f FeedForm) Merge(feed *model.Feed) *model.Feed { feed.Category.ID = f.CategoryID diff --git a/ui/subscription_choose.go b/ui/subscription_choose.go index 70a45b18..17efaeae 100644 --- a/ui/subscription_choose.go +++ b/ui/subscription_choose.go @@ -11,6 +11,7 @@ import ( "miniflux.app/http/request" "miniflux.app/http/response/html" "miniflux.app/http/route" + "miniflux.app/model" feedHandler "miniflux.app/reader/handler" "miniflux.app/ui/form" "miniflux.app/ui/session" @@ -48,8 +49,7 @@ func (h *handler) showChooseSubscriptionPage(w http.ResponseWriter, r *http.Requ return } - feed, err := feedHandler.CreateFeed(h.store, &feedHandler.FeedCreationArgs{ - UserID: user.ID, + feed, err := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{ CategoryID: subscriptionForm.CategoryID, FeedURL: subscriptionForm.URL, Crawler: subscriptionForm.Crawler, diff --git a/ui/subscription_submit.go b/ui/subscription_submit.go index a87a6dc3..5316ac2e 100644 --- a/ui/subscription_submit.go +++ b/ui/subscription_submit.go @@ -12,6 +12,7 @@ import ( "miniflux.app/http/response/html" "miniflux.app/http/route" "miniflux.app/logger" + "miniflux.app/model" feedHandler "miniflux.app/reader/handler" "miniflux.app/reader/subscription" "miniflux.app/ui/form" @@ -75,8 +76,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) { v.Set("errorMessage", "error.subscription_not_found") html.OK(w, r, v.Render("add_subscription")) case n == 1: - feed, err := feedHandler.CreateFeed(h.store, &feedHandler.FeedCreationArgs{ - UserID: user.ID, + feed, err := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{ CategoryID: subscriptionForm.CategoryID, FeedURL: subscriptions[0].URL, Crawler: subscriptionForm.Crawler, diff --git a/validator/feed.go b/validator/feed.go new file mode 100644 index 00000000..3c14d424 --- /dev/null +++ b/validator/feed.go @@ -0,0 +1,68 @@ +// Copyright 2021 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package validator // import "miniflux.app/validator" + +import ( + "miniflux.app/model" + "miniflux.app/storage" +) + +// ValidateFeedCreation validates feed creation. +func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.FeedCreationRequest) *ValidationError { + if request.FeedURL == "" || request.CategoryID <= 0 { + return NewValidationError("error.feed_mandatory_fields") + } + + if !isValidURL(request.FeedURL) { + return NewValidationError("error.invalid_feed_url") + } + + if store.FeedURLExists(userID, request.FeedURL) { + return NewValidationError("error.feed_already_exists") + } + + if !store.CategoryIDExists(userID, request.CategoryID) { + return NewValidationError("error.feed_category_not_found") + } + + return nil +} + +// ValidateFeedModification validates feed modification. +func ValidateFeedModification(store *storage.Storage, userID int64, request *model.FeedModificationRequest) *ValidationError { + if request.FeedURL != nil { + if *request.FeedURL == "" { + return NewValidationError("error.feed_url_not_empty") + } + + if !isValidURL(*request.FeedURL) { + return NewValidationError("error.invalid_feed_url") + } + } + + if request.SiteURL != nil { + if *request.SiteURL == "" { + return NewValidationError("error.site_url_not_empty") + } + + if !isValidURL(*request.SiteURL) { + return NewValidationError("error.invalid_site_url") + } + } + + if request.Title != nil { + if *request.Title == "" { + return NewValidationError("error.feed_title_not_empty") + } + } + + if request.CategoryID != nil { + if !store.CategoryIDExists(userID, *request.CategoryID) { + return NewValidationError("error.feed_category_not_found") + } + } + + return nil +} diff --git a/validator/subscription.go b/validator/subscription.go new file mode 100644 index 00000000..6d1cc9c9 --- /dev/null +++ b/validator/subscription.go @@ -0,0 +1,16 @@ +// Copyright 2021 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package validator // import "miniflux.app/validator" + +import "miniflux.app/model" + +// ValidateSubscriptionDiscovery validates subscription discovery requests. +func ValidateSubscriptionDiscovery(request *model.SubscriptionDiscoveryRequest) *ValidationError { + if !isValidURL(request.URL) { + return NewValidationError("error.invalid_site_url") + } + + return nil +} diff --git a/validator/validator.go b/validator/validator.go index 0fd8b07b..8abb4c94 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -6,6 +6,7 @@ package validator // import "miniflux.app/validator" import ( "errors" + "net/url" "miniflux.app/locale" ) @@ -27,3 +28,8 @@ func (v *ValidationError) String() string { func (v *ValidationError) Error() error { return errors.New(v.String()) } + +func isValidURL(absoluteURL string) bool { + _, err := url.ParseRequestURI(absoluteURL) + return err == nil +} diff --git a/validator/validator_test.go b/validator/validator_test.go new file mode 100644 index 00000000..0831b465 --- /dev/null +++ b/validator/validator_test.go @@ -0,0 +1,22 @@ +// Copyright 2021 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package validator // import "miniflux.app/validator" + +import "testing" + +func TestIsValidURL(t *testing.T) { + scenarios := map[string]bool{ + "https://www.example.org": true, + "http://www.example.org/": true, + "www.example.org": false, + } + + for link, expected := range scenarios { + result := isValidURL(link) + if result != expected { + t.Errorf(`Unexpected result, got %v instead of %v`, result, expected) + } + } +}