Browse Source

Refactor feed validator

Frédéric Guillot 5 years ago
parent
commit
806b9545a9

+ 1 - 1
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)

+ 15 - 44
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 {
-		json.BadRequest(w, r, err)
-		return
-	}
-
-	if feedInfo.FeedURL == "" {
-		json.BadRequest(w, r, errors.New("The feed_url is required"))
-		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"))
+	var feedCreationRequest model.FeedCreationRequest
+	if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {
+		json.BadRequest(w, r, err)
 		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"))
+	if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
+		json.BadRequest(w, r, validationErr.Error())
 		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

+ 0 - 140
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"`

+ 0 - 220
api/payload_test.go

@@ -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)
-	}
-}

+ 17 - 9
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)

+ 1 - 2
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)
 	}
 

+ 88 - 11
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",
 }

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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キーを作成できません。",

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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.",

+ 7 - 0
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密钥。",

+ 105 - 6
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

+ 8 - 0
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
+}

+ 14 - 0
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"`
+}

+ 22 - 40
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
 }
 

+ 88 - 26
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`)
 	}
+}
+
+func TestUpdateFeedWithInvalidFeedURL(t *testing.T) {
+	client := createClient(t)
+	feed, _ := createFeed(t, client)
 
-	if updatedFeed.FeedURL == "" {
-		t.Fatalf(`The FeedURL should not be empty`)
+	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`)
 	}
+}
+
+func TestUpdateFeedWithInvalidCategoryID(t *testing.T) {
+	client := createClient(t)
+	feed, _ := createFeed(t, client)
 
-	if updatedFeed.Category.ID == 0 {
-		t.Fatalf(`The CategoryID must defined`)
+	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`)
 	}
 }
 

+ 18 - 0
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`)
+	}
+}

+ 17 - 8
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
 	}

+ 0 - 9
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

+ 2 - 2
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,

+ 2 - 2
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,

+ 68 - 0
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
+}

+ 16 - 0
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
+}

+ 6 - 0
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
+}

+ 22 - 0
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)
+		}
+	}
+}