diff --git a/api/payload.go b/api/payload.go index 70a1a391..9b9256ac 100644 --- a/api/payload.go +++ b/api/payload.go @@ -167,113 +167,6 @@ func decodeFeedModificationRequest(r io.ReadCloser) (*feedModificationRequest, e return &feed, nil } -type userCreationRequest struct { - Username string `json:"username"` - Password string `json:"password"` - IsAdmin bool `json:"is_admin"` - GoogleID string `json:"google_id"` - OpenIDConnectID string `json:"openid_connect_id"` -} - -func decodeUserCreationRequest(r io.ReadCloser) (*userCreationRequest, error) { - defer r.Close() - - var request userCreationRequest - decoder := json.NewDecoder(r) - if err := decoder.Decode(&request); err != nil { - return nil, fmt.Errorf("Unable to decode user creation JSON object: %v", err) - } - - return &request, nil -} - -type userModificationRequest struct { - Username *string `json:"username"` - Password *string `json:"password"` - IsAdmin *bool `json:"is_admin"` - Theme *string `json:"theme"` - Language *string `json:"language"` - Timezone *string `json:"timezone"` - EntryDirection *string `json:"entry_sorting_direction"` - Stylesheet *string `json:"stylesheet"` - GoogleID *string `json:"google_id"` - OpenIDConnectID *string `json:"openid_connect_id"` - EntriesPerPage *int `json:"entries_per_page"` - KeyboardShortcuts *bool `json:"keyboard_shortcuts"` - ShowReadingTime *bool `json:"show_reading_time"` - EntrySwipe *bool `json:"entry_swipe"` -} - -func (u *userModificationRequest) Update(user *model.User) { - if u.Username != nil { - user.Username = *u.Username - } - - if u.Password != nil { - user.Password = *u.Password - } - - if u.IsAdmin != nil { - user.IsAdmin = *u.IsAdmin - } - - if u.Theme != nil { - user.Theme = *u.Theme - } - - if u.Language != nil { - user.Language = *u.Language - } - - if u.Timezone != nil { - user.Timezone = *u.Timezone - } - - if u.EntryDirection != nil { - user.EntryDirection = *u.EntryDirection - } - - if u.Stylesheet != nil { - user.Stylesheet = *u.Stylesheet - } - - if u.GoogleID != nil { - user.GoogleID = *u.GoogleID - } - - if u.OpenIDConnectID != nil { - user.OpenIDConnectID = *u.OpenIDConnectID - } - - if u.EntriesPerPage != nil { - user.EntriesPerPage = *u.EntriesPerPage - } - - if u.KeyboardShortcuts != nil { - user.KeyboardShortcuts = *u.KeyboardShortcuts - } - - if u.ShowReadingTime != nil { - user.ShowReadingTime = *u.ShowReadingTime - } - - if u.EntrySwipe != nil { - user.EntrySwipe = *u.EntrySwipe - } -} - -func decodeUserModificationRequest(r io.ReadCloser) (*userModificationRequest, error) { - defer r.Close() - - var request userModificationRequest - decoder := json.NewDecoder(r) - if err := decoder.Decode(&request); err != nil { - return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err) - } - - return &request, 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 index 18831eaf..cb43b02d 100644 --- a/api/payload_test.go +++ b/api/payload_test.go @@ -218,24 +218,3 @@ func TestUpdateFeedToFetchViaProxy(t *testing.T) { t.Errorf(`The field FetchViaProxy should be %v`, value) } } - -func TestUpdateUserTheme(t *testing.T) { - theme := "Example 2" - changes := &userModificationRequest{Theme: &theme} - user := &model.User{Theme: "Example"} - changes.Update(user) - - if user.Theme != theme { - t.Errorf(`Unexpected value, got %q instead of %q`, user.Theme, theme) - } -} - -func TestUserThemeWhenNotSet(t *testing.T) { - changes := &userModificationRequest{} - user := &model.User{Theme: "Example"} - changes.Update(user) - - if user.Theme != "Example" { - t.Error(`The user Theme should not be modified`) - } -} diff --git a/api/user.go b/api/user.go index a522a27e..cd40bc68 100644 --- a/api/user.go +++ b/api/user.go @@ -5,12 +5,14 @@ package api // import "miniflux.app/api" import ( + json_parser "encoding/json" "errors" "net/http" "miniflux.app/http/request" "miniflux.app/http/response/json" "miniflux.app/model" + "miniflux.app/validator" ) func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) { @@ -29,50 +31,38 @@ func (h *handler) createUser(w http.ResponseWriter, r *http.Request) { return } - userCreationRequest, err := decodeUserCreationRequest(r.Body) - if err != nil { + var userCreationRequest model.UserCreationRequest + if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil { json.BadRequest(w, r, err) return } - user := model.NewUser() - user.Username = userCreationRequest.Username - user.Password = userCreationRequest.Password - user.IsAdmin = userCreationRequest.IsAdmin - user.GoogleID = userCreationRequest.GoogleID - user.OpenIDConnectID = userCreationRequest.OpenIDConnectID - - if err := user.ValidateUserCreation(); err != nil { - json.BadRequest(w, r, err) + if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil { + json.BadRequest(w, r, validationErr.Error()) return } - if h.store.UserExists(user.Username) { - json.BadRequest(w, r, errors.New("This user already exists")) - return - } - - err = h.store.CreateUser(user) + user, err := h.store.CreateUser(&userCreationRequest) if err != nil { json.ServerError(w, r, err) return } - user.Password = "" json.Created(w, r, user) } func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) { userID := request.RouteInt64Param(r, "userID") - userChanges, err := decodeUserModificationRequest(r.Body) - if err != nil { + + var userModificationRequest model.UserModificationRequest + if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil { json.BadRequest(w, r, err) return } originalUser, err := h.store.UserByID(userID) if err != nil { - json.BadRequest(w, r, errors.New("Unable to fetch this user from the database")) + json.ServerError(w, r, err) return } @@ -87,18 +77,18 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) { return } - if userChanges.IsAdmin != nil && *userChanges.IsAdmin { + if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin { json.BadRequest(w, r, errors.New("Only administrators can change permissions of standard users")) return } } - userChanges.Update(originalUser) - if err := originalUser.ValidateUserModification(); err != nil { - json.BadRequest(w, r, err) + if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil { + json.BadRequest(w, r, validationErr.Error()) return } + userModificationRequest.Patch(originalUser) if err = h.store.UpdateUser(originalUser); err != nil { json.ServerError(w, r, err) return diff --git a/cli/create_admin.go b/cli/create_admin.go index 8308c520..9eefc370 100644 --- a/cli/create_admin.go +++ b/cli/create_admin.go @@ -12,29 +12,31 @@ import ( "miniflux.app/logger" "miniflux.app/model" "miniflux.app/storage" + "miniflux.app/validator" ) func createAdmin(store *storage.Storage) { - user := model.NewUser() - user.Username = config.Opts.AdminUsername() - user.Password = config.Opts.AdminPassword() - user.IsAdmin = true - - if user.Username == "" || user.Password == "" { - user.Username, user.Password = askCredentials() + userCreationRequest := &model.UserCreationRequest{ + Username: config.Opts.AdminUsername(), + Password: config.Opts.AdminPassword(), + IsAdmin: true, } - if err := user.ValidateUserCreation(); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) + if userCreationRequest.Username == "" || userCreationRequest.Password == "" { + userCreationRequest.Username, userCreationRequest.Password = askCredentials() } - if store.UserExists(user.Username) { - logger.Info(`User %q already exists, skipping creation`, user.Username) + if store.UserExists(userCreationRequest.Username) { + logger.Info(`User %q already exists, skipping creation`, userCreationRequest.Username) return } - if err := store.CreateUser(user); err != nil { + if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil { + fmt.Fprintf(os.Stderr, "%s\n", validationErr) + os.Exit(1) + } + + if _, err := store.CreateUser(userCreationRequest); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } diff --git a/cli/reset_password.go b/cli/reset_password.go index 4b4f78e2..de2423d4 100644 --- a/cli/reset_password.go +++ b/cli/reset_password.go @@ -8,7 +8,9 @@ import ( "fmt" "os" + "miniflux.app/model" "miniflux.app/storage" + "miniflux.app/validator" ) func resetPassword(store *storage.Storage) { @@ -24,9 +26,11 @@ func resetPassword(store *storage.Storage) { os.Exit(1) } - user.Password = password - if err := user.ValidatePassword(); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) + userModificationRequest := &model.UserModificationRequest{ + Password: &password, + } + if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil { + fmt.Fprintf(os.Stderr, "%s\n", validationErr) os.Exit(1) } diff --git a/locale/translations.go b/locale/translations.go index de919ea5..0e73dfa1 100644 --- a/locale/translations.go +++ b/locale/translations.go @@ -244,6 +244,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Ungültiges Thema.", + "error.invalid_language": "Ungültige Sprache.", + "error.invalid_timezone": "Ungültige Zeitzone.", + "error.invalid_entry_direction": "Ungültige Sortierreihenfolge.", "form.feed.label.title": "Titel", "form.feed.label.site_url": "Webseite-URL", "form.feed.label.feed_url": "Abonnement-URL", @@ -582,6 +586,10 @@ var translations = map[string]string{ "error.unable_to_update_user": "Unable to update this user.", "error.unable_to_update_feed": "Unable to update this feed.", "error.subscription_not_found": "Unable to find any subscription.", + "error.invalid_theme": "Invalid theme.", + "error.invalid_language": "Invalid language.", + "error.invalid_timezone": "Invalid timezone.", + "error.invalid_entry_direction": "Invalid entry direction.", "error.empty_file": "This file is empty.", "error.bad_credentials": "Invalid username or password.", "error.fields_mandatory": "All fields are mandatory.", @@ -924,6 +932,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Tema no válido.", + "error.invalid_language": "Idioma no válido.", + "error.invalid_timezone": "Zona horaria no válida.", + "error.invalid_entry_direction": "Dirección de entrada no válida.", "form.feed.label.title": "Título", "form.feed.label.site_url": "URL del sitio", "form.feed.label.feed_url": "URL de la fuente", @@ -936,7 +948,6 @@ var translations = map[string]string{ "form.feed.label.rewrite_rules": "Reglas de reescribir", "form.feed.label.blocklist_rules": "Reglas de Filtrado(Bloquear)", "form.feed.label.keeplist_rules": "Reglas de Filtrado(Permitir)", - "form.feed.label.blocklist_rules": "Reglas de Blacklist", "form.feed.label.ignore_http_cache": "Ignorar caché HTTP", "form.feed.label.fetch_via_proxy": "Buscar a través de proxy", "form.feed.label.disabled": "No actualice este feed", @@ -1255,6 +1266,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Thème non valide.", + "error.invalid_language": "Langue non valide.", + "error.invalid_timezone": "Fuseau horaire non valide.", + "error.invalid_entry_direction": "Ordre de trie non valide.", "form.feed.label.title": "Titre", "form.feed.label.site_url": "URL du site web", "form.feed.label.feed_url": "URL du flux", @@ -1605,6 +1620,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Tema non valido.", + "error.invalid_language": "Lingua non valida.", + "error.invalid_timezone": "Fuso orario non valido.", + "error.invalid_entry_direction": "Ordinamento non valido.", "form.feed.label.title": "Titolo", "form.feed.label.site_url": "URL del sito", "form.feed.label.feed_url": "URL del feed", @@ -1935,6 +1954,10 @@ var translations = map[string]string{ "error.user_mandatory_fields": "ユーザー名が必要です。", "error.api_key_already_exists": "このAPIキーは既に存在します。", "error.unable_to_create_api_key": "このAPIキーを作成できません。", + "error.invalid_theme": "テーマが無効です。", + "error.invalid_language": "言語が無効です。", + "error.invalid_timezone": "タイムゾーンが無効です。", + "error.invalid_entry_direction": "ソート順が無効です。", "form.feed.label.title": "タイトル", "form.feed.label.site_url": "サイト URL", "form.feed.label.feed_url": "フィード URL", @@ -2265,6 +2288,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Ongeldig thema.", + "error.invalid_language": "Ongeldige taal.", + "error.invalid_timezone": "Ongeldige tijdzone.", + "error.invalid_entry_direction": "Ongeldige sorteervolgorde.", "form.feed.label.title": "Naam", "form.feed.label.site_url": "Website URL", "form.feed.label.feed_url": "Feed URL", @@ -2615,6 +2642,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Nieprawidłowy motyw.", + "error.invalid_language": "Nieprawidłowy język.", + "error.invalid_timezone": "Nieprawidłowa strefa czasowa.", + "error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.", "form.feed.label.title": "Tytuł", "form.feed.label.site_url": "URL strony", "form.feed.label.feed_url": "URL kanału", @@ -2969,6 +3000,10 @@ var translations = map[string]string{ "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.", + "error.invalid_theme": "Tema inválido.", + "error.invalid_language": "Idioma inválido.", + "error.invalid_timezone": "Fuso horário inválido.", + "error.invalid_entry_direction": "Direção de entrada inválida.", "form.feed.label.title": "Título", "form.feed.label.site_url": "URL do site", "form.feed.label.feed_url": "URL da fonte", @@ -3301,6 +3336,10 @@ var translations = map[string]string{ "error.user_mandatory_fields": "Имя пользователя обязательно.", "error.api_key_already_exists": "Этот ключ API уже существует.", "error.unable_to_create_api_key": "Невозможно создать этот ключ API.", + "error.invalid_theme": "Неверная тема.", + "error.invalid_language": "Неверный язык.", + "error.invalid_timezone": "Неверный часовой пояс.", + "error.invalid_entry_direction": "Неверное направление входа.", "form.feed.label.title": "Название", "form.feed.label.site_url": "URL сайта", "form.feed.label.feed_url": "URL подписки", @@ -3635,6 +3674,10 @@ var translations = map[string]string{ "error.user_mandatory_fields": "必须填写用户名", "error.api_key_already_exists": "此API密钥已存在。", "error.unable_to_create_api_key": "无法创建此API密钥。", + "error.invalid_theme": "无效的主题。", + "error.invalid_language": "语言无效。", + "error.invalid_timezone": "无效的时区。", + "error.invalid_entry_direction": "无效的输入方向。", "form.feed.label.title": "标题", "form.feed.label.site_url": "站点 URL", "form.feed.label.feed_url": "源 URL", @@ -3740,15 +3783,15 @@ var translations = map[string]string{ } var translationsChecksums = map[string]string{ - "de_DE": "66d6feafa5f92c35b10f8c07aa693afe1ea7dca73708cdebfc4cf3edb0478512", - "en_US": "fa1771f155b439b46f282069a1628dc95d4170a5d7db14c1b90fa753936e7856", - "es_ES": "5b65c97c14e4b1f833ce6047be4b8b29bcb777d7a7a21420d1fb619584746649", - "fr_FR": "b58d74f73de2e775cc3f883f2fb19451e306ff32772f2fa8cb3c630d9df395d3", - "it_IT": "a488f947b4e0cd8149c4cf7aaa71a8c59976a06559046fbc5b36aa167caaa84c", - "ja_JP": "9a3d1484c46be56286f9abf06b596576a3ae44f571d72a40d2eba5bfb02dd921", - "nl_NL": "3b91987a9f6640e827c73ca11bb0f9bc58ff79359e792038746e273cd7ae546d", - "pl_PL": "ff1dab97559d16331c374c63a91000b0c27f796bd96595e34ca324eb68a7c06e", - "pt_BR": "524c0f0dcd81988acd286900481e1a2e0ca9cf789d752e22da8bd5fe31b6acf3", - "ru_RU": "805c698b8a053860f737a145acc3616c67c8d80bf1de89bf6bb27fee40e885f2", - "zh_CN": "0c6be862c7bd997337696161a1c1d3d2ec5c7adab9c33f29c09d906fcc015a79", + "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", } diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json index cb0503b3..b2814e2e 100644 --- a/locale/translations/de_DE.json +++ b/locale/translations/de_DE.json @@ -239,6 +239,10 @@ "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.", + "error.invalid_theme": "Ungültiges Thema.", + "error.invalid_language": "Ungültige Sprache.", + "error.invalid_timezone": "Ungültige Zeitzone.", + "error.invalid_entry_direction": "Ungültige Sortierreihenfolge.", "form.feed.label.title": "Titel", "form.feed.label.site_url": "Webseite-URL", "form.feed.label.feed_url": "Abonnement-URL", diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json index 298b9a45..0f0f578b 100644 --- a/locale/translations/en_US.json +++ b/locale/translations/en_US.json @@ -227,6 +227,10 @@ "error.unable_to_update_user": "Unable to update this user.", "error.unable_to_update_feed": "Unable to update this feed.", "error.subscription_not_found": "Unable to find any subscription.", + "error.invalid_theme": "Invalid theme.", + "error.invalid_language": "Invalid language.", + "error.invalid_timezone": "Invalid timezone.", + "error.invalid_entry_direction": "Invalid entry direction.", "error.empty_file": "This file is empty.", "error.bad_credentials": "Invalid username or password.", "error.fields_mandatory": "All fields are mandatory.", diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json index 29dbfa81..d36af225 100644 --- a/locale/translations/es_ES.json +++ b/locale/translations/es_ES.json @@ -239,6 +239,10 @@ "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.", + "error.invalid_theme": "Tema no válido.", + "error.invalid_language": "Idioma no válido.", + "error.invalid_timezone": "Zona horaria no válida.", + "error.invalid_entry_direction": "Dirección de entrada no válida.", "form.feed.label.title": "Título", "form.feed.label.site_url": "URL del sitio", "form.feed.label.feed_url": "URL de la fuente", @@ -251,7 +255,6 @@ "form.feed.label.rewrite_rules": "Reglas de reescribir", "form.feed.label.blocklist_rules": "Reglas de Filtrado(Bloquear)", "form.feed.label.keeplist_rules": "Reglas de Filtrado(Permitir)", - "form.feed.label.blocklist_rules": "Reglas de Blacklist", "form.feed.label.ignore_http_cache": "Ignorar caché HTTP", "form.feed.label.fetch_via_proxy": "Buscar a través de proxy", "form.feed.label.disabled": "No actualice este feed", diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index b6fc0e9b..da0e50fd 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -239,6 +239,10 @@ "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.", + "error.invalid_theme": "Thème non valide.", + "error.invalid_language": "Langue non valide.", + "error.invalid_timezone": "Fuseau horaire non valide.", + "error.invalid_entry_direction": "Ordre de trie non valide.", "form.feed.label.title": "Titre", "form.feed.label.site_url": "URL du site web", "form.feed.label.feed_url": "URL du flux", diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json index 06f284f0..da5a1fac 100644 --- a/locale/translations/it_IT.json +++ b/locale/translations/it_IT.json @@ -239,6 +239,10 @@ "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.", + "error.invalid_theme": "Tema non valido.", + "error.invalid_language": "Lingua non valida.", + "error.invalid_timezone": "Fuso orario non valido.", + "error.invalid_entry_direction": "Ordinamento non valido.", "form.feed.label.title": "Titolo", "form.feed.label.site_url": "URL del sito", "form.feed.label.feed_url": "URL del feed", diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json index 7dd918da..0253b5e4 100644 --- a/locale/translations/ja_JP.json +++ b/locale/translations/ja_JP.json @@ -239,6 +239,10 @@ "error.user_mandatory_fields": "ユーザー名が必要です。", "error.api_key_already_exists": "このAPIキーは既に存在します。", "error.unable_to_create_api_key": "このAPIキーを作成できません。", + "error.invalid_theme": "テーマが無効です。", + "error.invalid_language": "言語が無効です。", + "error.invalid_timezone": "タイムゾーンが無効です。", + "error.invalid_entry_direction": "ソート順が無効です。", "form.feed.label.title": "タイトル", "form.feed.label.site_url": "サイト URL", "form.feed.label.feed_url": "フィード URL", diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json index 7261a581..a84248f0 100644 --- a/locale/translations/nl_NL.json +++ b/locale/translations/nl_NL.json @@ -239,6 +239,10 @@ "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.", + "error.invalid_theme": "Ongeldig thema.", + "error.invalid_language": "Ongeldige taal.", + "error.invalid_timezone": "Ongeldige tijdzone.", + "error.invalid_entry_direction": "Ongeldige sorteervolgorde.", "form.feed.label.title": "Naam", "form.feed.label.site_url": "Website URL", "form.feed.label.feed_url": "Feed URL", diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json index d4490b67..acfafcc7 100644 --- a/locale/translations/pl_PL.json +++ b/locale/translations/pl_PL.json @@ -241,6 +241,10 @@ "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.", + "error.invalid_theme": "Nieprawidłowy motyw.", + "error.invalid_language": "Nieprawidłowy język.", + "error.invalid_timezone": "Nieprawidłowa strefa czasowa.", + "error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.", "form.feed.label.title": "Tytuł", "form.feed.label.site_url": "URL strony", "form.feed.label.feed_url": "URL kanału", diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json index 6c41e9cc..d864bc59 100644 --- a/locale/translations/pt_BR.json +++ b/locale/translations/pt_BR.json @@ -239,6 +239,10 @@ "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.", + "error.invalid_theme": "Tema inválido.", + "error.invalid_language": "Idioma inválido.", + "error.invalid_timezone": "Fuso horário inválido.", + "error.invalid_entry_direction": "Direção de entrada inválida.", "form.feed.label.title": "Título", "form.feed.label.site_url": "URL do site", "form.feed.label.feed_url": "URL da fonte", diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json index 2f46eb2f..9579cf17 100644 --- a/locale/translations/ru_RU.json +++ b/locale/translations/ru_RU.json @@ -241,6 +241,10 @@ "error.user_mandatory_fields": "Имя пользователя обязательно.", "error.api_key_already_exists": "Этот ключ API уже существует.", "error.unable_to_create_api_key": "Невозможно создать этот ключ API.", + "error.invalid_theme": "Неверная тема.", + "error.invalid_language": "Неверный язык.", + "error.invalid_timezone": "Неверный часовой пояс.", + "error.invalid_entry_direction": "Неверное направление входа.", "form.feed.label.title": "Название", "form.feed.label.site_url": "URL сайта", "form.feed.label.feed_url": "URL подписки", diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json index 22dd604a..30b4916b 100644 --- a/locale/translations/zh_CN.json +++ b/locale/translations/zh_CN.json @@ -237,6 +237,10 @@ "error.user_mandatory_fields": "必须填写用户名", "error.api_key_already_exists": "此API密钥已存在。", "error.unable_to_create_api_key": "无法创建此API密钥。", + "error.invalid_theme": "无效的主题。", + "error.invalid_language": "语言无效。", + "error.invalid_timezone": "无效的时区。", + "error.invalid_entry_direction": "无效的输入方向。", "form.feed.label.title": "标题", "form.feed.label.site_url": "站点 URL", "form.feed.label.feed_url": "源 URL", diff --git a/locale/translations_test.go b/locale/translations_test.go index 55775bae..61fd3068 100644 --- a/locale/translations_test.go +++ b/locale/translations_test.go @@ -29,11 +29,11 @@ func TestAllKeysHaveValue(t *testing.T) { switch value := v.(type) { case string: if value == "" { - t.Fatalf(`The key %q for the language %q have an empty string as value`, k, language) + t.Errorf(`The key %q for the language %q have an empty string as value`, k, language) } case []string: if len(value) == 0 { - t.Fatalf(`The key %q for the language %q have an empty list as value`, k, language) + t.Errorf(`The key %q for the language %q have an empty list as value`, k, language) } } } diff --git a/model/model.go b/model/model.go new file mode 100644 index 00000000..5f9a5708 --- /dev/null +++ b/model/model.go @@ -0,0 +1,21 @@ +// 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 model // import "miniflux.app/model" + +// OptionalString populates an optional string field. +func OptionalString(value string) *string { + if value != "" { + return &value + } + return nil +} + +// OptionalInt populates an optional int field. +func OptionalInt(value int) *int { + if value > 0 { + return &value + } + return nil +} diff --git a/model/theme.go b/model/theme.go index d9f07630..c8dc3805 100644 --- a/model/theme.go +++ b/model/theme.go @@ -4,8 +4,6 @@ package model // import "miniflux.app/model" -import "miniflux.app/errors" - // Themes returns the list of available themes. func Themes() map[string]string { return map[string]string{ @@ -29,14 +27,3 @@ func ThemeColor(theme string) string { return "#fff" } } - -// ValidateTheme validates theme value. -func ValidateTheme(theme string) error { - for key := range Themes() { - if key == theme { - return nil - } - } - - return errors.NewLocalizedError("Invalid theme") -} diff --git a/model/theme_test.go b/model/theme_test.go deleted file mode 100644 index 30d76697..00000000 --- a/model/theme_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2017 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" - -import "testing" - -func TestValidateTheme(t *testing.T) { - for _, status := range []string{"light_serif", "dark_sans_serif", "system_serif"} { - if err := ValidateTheme(status); err != nil { - t.Error(`A valid theme should not generate any error`) - } - } - - if err := ValidateTheme("invalid"); err == nil { - t.Error(`An invalid theme should generate a error`) - } -} diff --git a/model/user.go b/model/user.go index b51b0d72..5453a41a 100644 --- a/model/user.go +++ b/model/user.go @@ -5,7 +5,6 @@ package model // import "miniflux.app/model" import ( - "errors" "time" "miniflux.app/timezone" @@ -15,7 +14,7 @@ import ( type User struct { ID int64 `json:"id"` Username string `json:"username"` - Password string `json:"password,omitempty"` + Password string `json:"-"` IsAdmin bool `json:"is_admin"` Theme string `json:"theme"` Language string `json:"language"` @@ -28,56 +27,93 @@ type User struct { KeyboardShortcuts bool `json:"keyboard_shortcuts"` ShowReadingTime bool `json:"show_reading_time"` EntrySwipe bool `json:"entry_swipe"` - LastLoginAt *time.Time `json:"last_login_at,omitempty"` + LastLoginAt *time.Time `json:"last_login_at"` } -// NewUser returns a new User. -func NewUser() *User { - return &User{} +// UserCreationRequest represents the request to create a user. +type UserCreationRequest struct { + Username string `json:"username"` + Password string `json:"password"` + IsAdmin bool `json:"is_admin"` + GoogleID string `json:"google_id"` + OpenIDConnectID string `json:"openid_connect_id"` } -// ValidateUserCreation validates new user. -func (u User) ValidateUserCreation() error { - if err := u.ValidateUserLogin(); err != nil { - return err - } - - return u.ValidatePassword() +// UserModificationRequest represents the request to update a user. +type UserModificationRequest struct { + Username *string `json:"username"` + Password *string `json:"password"` + Theme *string `json:"theme"` + Language *string `json:"language"` + Timezone *string `json:"timezone"` + EntryDirection *string `json:"entry_sorting_direction"` + Stylesheet *string `json:"stylesheet"` + GoogleID *string `json:"google_id"` + OpenIDConnectID *string `json:"openid_connect_id"` + EntriesPerPage *int `json:"entries_per_page"` + IsAdmin *bool `json:"is_admin"` + KeyboardShortcuts *bool `json:"keyboard_shortcuts"` + ShowReadingTime *bool `json:"show_reading_time"` + EntrySwipe *bool `json:"entry_swipe"` } -// ValidateUserModification validates user modification payload. -func (u User) ValidateUserModification() error { - if u.Theme != "" { - return ValidateTheme(u.Theme) +// Patch updates the User object with the modification request. +func (u *UserModificationRequest) Patch(user *User) { + if u.Username != nil { + user.Username = *u.Username } - if u.Password != "" { - return u.ValidatePassword() + if u.Password != nil { + user.Password = *u.Password } - return nil -} - -// ValidateUserLogin validates user credential requirements. -func (u User) ValidateUserLogin() error { - if u.Username == "" { - return errors.New("The username is mandatory") + if u.IsAdmin != nil { + user.IsAdmin = *u.IsAdmin } - if u.Password == "" { - return errors.New("The password is mandatory") + if u.Theme != nil { + user.Theme = *u.Theme } - return nil -} - -// ValidatePassword validates user password requirements. -func (u User) ValidatePassword() error { - if u.Password != "" && len(u.Password) < 6 { - return errors.New("The password must have at least 6 characters") + if u.Language != nil { + user.Language = *u.Language } - return nil + if u.Timezone != nil { + user.Timezone = *u.Timezone + } + + if u.EntryDirection != nil { + user.EntryDirection = *u.EntryDirection + } + + if u.Stylesheet != nil { + user.Stylesheet = *u.Stylesheet + } + + if u.GoogleID != nil { + user.GoogleID = *u.GoogleID + } + + if u.OpenIDConnectID != nil { + user.OpenIDConnectID = *u.OpenIDConnectID + } + + if u.EntriesPerPage != nil { + user.EntriesPerPage = *u.EntriesPerPage + } + + if u.KeyboardShortcuts != nil { + user.KeyboardShortcuts = *u.KeyboardShortcuts + } + + if u.ShowReadingTime != nil { + user.ShowReadingTime = *u.ShowReadingTime + } + + if u.EntrySwipe != nil { + user.EntrySwipe = *u.EntrySwipe + } } // UseTimezone converts last login date to the given timezone. diff --git a/model/user_test.go b/model/user_test.go deleted file mode 100644 index 99547271..00000000 --- a/model/user_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2017 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" - -import "testing" - -func TestValidateUserCreation(t *testing.T) { - user := &User{} - if err := user.ValidateUserCreation(); err == nil { - t.Error(`An empty user should generate an error`) - } - - user = &User{Username: "test", Password: ""} - if err := user.ValidateUserCreation(); err == nil { - t.Error(`User without password should generate an error`) - } - - user = &User{Username: "test", Password: "a"} - if err := user.ValidateUserCreation(); err == nil { - t.Error(`Passwords shorter than 6 characters should generate an error`) - } - - user = &User{Username: "", Password: "secret"} - if err := user.ValidateUserCreation(); err == nil { - t.Error(`An empty username should generate an error`) - } - - user = &User{Username: "test", Password: "secret"} - if err := user.ValidateUserCreation(); err != nil { - t.Error(`A valid user should not generate any error`) - } -} - -func TestValidateUserModification(t *testing.T) { - user := &User{} - if err := user.ValidateUserModification(); err != nil { - t.Error(`There is no changes, so we should not have an error`) - } - - user = &User{Theme: "system_serif"} - if err := user.ValidateUserModification(); err != nil { - t.Error(`A valid theme should not generate any errors`) - } - - user = &User{Theme: "invalid theme"} - if err := user.ValidateUserModification(); err == nil { - t.Error(`An invalid theme should generate an error`) - } - - user = &User{Password: "test123"} - if err := user.ValidateUserModification(); err != nil { - t.Error(`A valid password should not generate any errors`) - } - - user = &User{Password: "a"} - if err := user.ValidateUserModification(); err == nil { - t.Error(`An invalid password should generate an error`) - } -} diff --git a/oauth2/google.go b/oauth2/google.go index 9af11a23..d0685f0b 100644 --- a/oauth2/google.go +++ b/oauth2/google.go @@ -57,6 +57,10 @@ func (g *googleProvider) GetProfile(ctx context.Context, code string) (*Profile, return profile, nil } +func (g *googleProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) { + user.GoogleID = profile.ID +} + func (g *googleProvider) PopulateUserWithProfileID(user *model.User, profile *Profile) { user.GoogleID = profile.ID } diff --git a/oauth2/oidc.go b/oauth2/oidc.go index 8701084f..26d73433 100644 --- a/oauth2/oidc.go +++ b/oauth2/oidc.go @@ -44,6 +44,10 @@ func (o *oidcProvider) GetProfile(ctx context.Context, code string) (*Profile, e return profile, nil } +func (o *oidcProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) { + user.OpenIDConnectID = profile.ID +} + func (o *oidcProvider) PopulateUserWithProfileID(user *model.User, profile *Profile) { user.OpenIDConnectID = profile.ID } diff --git a/oauth2/provider.go b/oauth2/provider.go index d767129f..a1fbe94c 100644 --- a/oauth2/provider.go +++ b/oauth2/provider.go @@ -15,6 +15,7 @@ type Provider interface { GetUserExtraKey() string GetRedirectURL(state string) string GetProfile(ctx context.Context, code string) (*Profile, error) + PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) PopulateUserWithProfileID(user *model.User, profile *Profile) UnsetUserProfileID(user *model.User) } diff --git a/storage/entry_query_builder.go b/storage/entry_query_builder.go index f86afce6..c1204cf9 100644 --- a/storage/entry_query_builder.go +++ b/storage/entry_query_builder.go @@ -166,13 +166,17 @@ func (e *EntryQueryBuilder) WithDirection(direction string) *EntryQueryBuilder { // WithLimit set the limit. func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder { - e.limit = limit + if limit > 0 { + e.limit = limit + } return e } // WithOffset set the offset. func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder { - e.offset = offset + if offset > 0 { + e.offset = offset + } return e } @@ -370,11 +374,11 @@ func (e *EntryQueryBuilder) buildSorting() string { parts = append(parts, fmt.Sprintf(`%s`, e.direction)) } - if e.limit != 0 { + if e.limit > 0 { parts = append(parts, fmt.Sprintf(`LIMIT %d`, e.limit)) } - if e.offset != 0 { + if e.offset > 0 { parts = append(parts, fmt.Sprintf(`OFFSET %d`, e.offset)) } diff --git a/storage/user.go b/storage/user.go index 8a6879b3..2d539e1b 100644 --- a/storage/user.go +++ b/storage/user.go @@ -54,12 +54,13 @@ func (s *Storage) AnotherUserExists(userID int64, username string) bool { } // CreateUser creates a new user. -func (s *Storage) CreateUser(user *model.User) (err error) { - hashedPassword := "" - if user.Password != "" { - hashedPassword, err = hashPassword(user.Password) +func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*model.User, error) { + var hashedPassword string + if userCreationRequest.Password != "" { + var err error + hashedPassword, err = hashPassword(userCreationRequest.Password) if err != nil { - return err + return nil, err } } @@ -87,10 +88,18 @@ func (s *Storage) CreateUser(user *model.User) (err error) { tx, err := s.db.Begin() if err != nil { - return fmt.Errorf(`store: unable to start transaction: %v`, err) + return nil, fmt.Errorf(`store: unable to start transaction: %v`, err) } - err = tx.QueryRow(query, user.Username, hashedPassword, user.IsAdmin, user.GoogleID, user.OpenIDConnectID).Scan( + var user model.User + err = tx.QueryRow( + query, + userCreationRequest.Username, + hashedPassword, + userCreationRequest.IsAdmin, + userCreationRequest.GoogleID, + userCreationRequest.OpenIDConnectID, + ).Scan( &user.ID, &user.Username, &user.IsAdmin, @@ -108,26 +117,26 @@ func (s *Storage) CreateUser(user *model.User) (err error) { ) if err != nil { tx.Rollback() - return fmt.Errorf(`store: unable to create user: %v`, err) + return nil, fmt.Errorf(`store: unable to create user: %v`, err) } _, err = tx.Exec(`INSERT INTO categories (user_id, title) VALUES ($1, $2)`, user.ID, "All") if err != nil { tx.Rollback() - return fmt.Errorf(`store: unable to create user default category: %v`, err) + return nil, fmt.Errorf(`store: unable to create user default category: %v`, err) } _, err = tx.Exec(`INSERT INTO integrations (user_id) VALUES ($1)`, user.ID) if err != nil { tx.Rollback() - return fmt.Errorf(`store: unable to create integration row: %v`, err) + return nil, fmt.Errorf(`store: unable to create integration row: %v`, err) } if err := tx.Commit(); err != nil { - return fmt.Errorf(`store: unable to commit transaction: %v`, err) + return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err) } - return nil + return &user, nil } // UpdateUser updates a user. @@ -353,7 +362,7 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) { } func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, error) { - user := model.NewUser() + var user model.User err := s.db.QueryRow(query, args...).Scan( &user.ID, &user.Username, @@ -378,7 +387,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err return nil, fmt.Errorf(`store: unable to fetch user: %v`, err) } - return user, nil + return &user, nil } // RemoveUser deletes a user. @@ -446,7 +455,7 @@ func (s *Storage) Users() (model.Users, error) { var users model.Users for rows.Next() { - user := model.NewUser() + var user model.User err := rows.Scan( &user.ID, &user.Username, @@ -469,7 +478,7 @@ func (s *Storage) Users() (model.Users, error) { return nil, fmt.Errorf(`store: unable to fetch users row: %v`, err) } - users = append(users, user) + users = append(users, &user) } return users, nil diff --git a/tests/user_test.go b/tests/user_test.go index c7511302..ad534298 100644 --- a/tests/user_test.go +++ b/tests/user_test.go @@ -312,13 +312,103 @@ func TestUpdateUserThemeWithInvalidValue(t *testing.T) { t.Fatal(err) } - theme := "something that doesn't exists" + theme := "invalid" _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{Theme: &theme}) if err == nil { t.Fatal(`Updating a user Theme with an invalid value should raise an error`) } } +func TestUpdateUserLanguageWithInvalidValue(t *testing.T) { + username := getRandomUsername() + client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := client.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + language := "invalid" + _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{Language: &language}) + if err == nil { + t.Fatal(`Updating a user language with an invalid value should raise an error`) + } +} + +func TestUpdateUserTimezoneWithInvalidValue(t *testing.T) { + username := getRandomUsername() + client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := client.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + timezone := "invalid" + _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{Timezone: &timezone}) + if err == nil { + t.Fatal(`Updating a user timezone with an invalid value should raise an error`) + } +} + +func TestUpdateUserEntriesPerPageWithInvalidValue(t *testing.T) { + username := getRandomUsername() + client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := client.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + entriesPerPage := -5 + _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{EntriesPerPage: &entriesPerPage}) + if err == nil { + t.Fatal(`Updating a user EntriesPerPage with an invalid value should raise an error`) + } +} + +func TestUpdateUserEntryDirectionWithInvalidValue(t *testing.T) { + username := getRandomUsername() + client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := client.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + entryDirection := "invalid" + _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{EntryDirection: &entryDirection}) + if err == nil { + t.Fatal(`Updating a user EntryDirection with an invalid value should raise an error`) + } +} + +func TestUpdateUserPasswordWithInvalidValue(t *testing.T) { + username := getRandomUsername() + client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := client.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + password := "short" + _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{Password: &password}) + if err == nil { + t.Fatal(`Updating a user password with an invalid value should raise an error`) + } +} + +func TestUpdateUserWithEmptyUsernameValue(t *testing.T) { + username := getRandomUsername() + client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := client.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + newUsername := "" + _, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{Username: &newUsername}) + if err == nil { + t.Fatal(`Updating a user with an empty username should raise an error`) + } +} + func TestCannotCreateDuplicateUser(t *testing.T) { username := getRandomUsername() client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) @@ -329,7 +419,7 @@ func TestCannotCreateDuplicateUser(t *testing.T) { _, err = client.CreateUser(username, testStandardPassword, false) if err == nil { - t.Fatal(`Duplicate users should not be allowed`) + t.Fatal(`Duplicated users should not be allowed`) } } diff --git a/ui/form/settings.go b/ui/form/settings.go index 9eb51f5d..b3b123bc 100644 --- a/ui/form/settings.go +++ b/ui/form/settings.go @@ -54,10 +54,6 @@ func (s *SettingsForm) Validate() error { return errors.NewLocalizedError("error.settings_mandatory_fields") } - if s.EntriesPerPage < 1 { - return errors.NewLocalizedError("error.entries_per_page_invalid") - } - if s.Confirmation == "" { // Firefox insists on auto-completing the password field. // If the confirmation field is blank, the user probably @@ -67,10 +63,6 @@ func (s *SettingsForm) Validate() error { if s.Password != s.Confirmation { return errors.NewLocalizedError("error.different_passwords") } - - if len(s.Password) < 6 { - return errors.NewLocalizedError("error.password_min_length") - } } return nil diff --git a/ui/form/settings_test.go b/ui/form/settings_test.go index 04548693..431c99eb 100644 --- a/ui/form/settings_test.go +++ b/ui/form/settings_test.go @@ -61,21 +61,3 @@ func TestConfirmationIncorrect(t *testing.T) { t.Error("Validate should return an error") } } - -func TestEntriesPerPageNotValid(t *testing.T) { - settings := &SettingsForm{ - Username: "user", - Password: "hunter2", - Confirmation: "hunter2", - Theme: "default", - Language: "en_US", - Timezone: "UTC", - EntryDirection: "asc", - EntriesPerPage: 0, - } - - err := settings.Validate() - if err == nil { - t.Error("Validate should return an error") - } -} diff --git a/ui/form/user.go b/ui/form/user.go index e2504626..7abaeca7 100644 --- a/ui/form/user.go +++ b/ui/form/user.go @@ -29,10 +29,6 @@ func (u UserForm) ValidateCreation() error { return errors.NewLocalizedError("error.different_passwords") } - if len(u.Password) < 6 { - return errors.NewLocalizedError("error.password_min_length") - } - return nil } @@ -55,15 +51,6 @@ func (u UserForm) ValidateModification() error { return nil } -// ToUser returns a User from the form values. -func (u UserForm) ToUser() *model.User { - return &model.User{ - Username: u.Username, - Password: u.Password, - IsAdmin: u.IsAdmin, - } -} - // Merge updates the fields of the given user. func (u UserForm) Merge(user *model.User) *model.User { user.Username = u.Username diff --git a/ui/middleware.go b/ui/middleware.go index ddd40def..067aa53d 100644 --- a/ui/middleware.go +++ b/ui/middleware.go @@ -177,10 +177,8 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler { return } - sess := session.New(m.store, request.SessionID(r)) clientIP := request.ClientIP(r) - - logger.Info("[AuthProxy] Successful auth for %s", username) + logger.Info("[AuthProxy] [ClientIP=%s] Received authenticated requested for %q", clientIP, username) user, err := m.store.UserByUsername(username) if err != nil { @@ -189,16 +187,14 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler { } if user == nil { + logger.Error("[AuthProxy] [ClientIP=%s] %q doesn't exist", clientIP, username) + if !config.Opts.IsAuthProxyUserCreationAllowed() { html.Forbidden(w, r) return } - user = model.NewUser() - user.Username = username - user.IsAdmin = false - - if err := m.store.CreateUser(user); err != nil { + if user, err = m.store.CreateUser(&model.UserCreationRequest{Username: username}); err != nil { html.ServerError(w, r, err) return } @@ -210,9 +206,11 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler { return } - logger.Info("[AuthProxy] username=%s just logged in", user.Username) + logger.Info("[AuthProxy] [ClientIP=%s] username=%s just logged in", clientIP, user.Username) m.store.SetLastLogin(user.ID) + + sess := session.New(m.store, request.SessionID(r)) sess.SetLanguage(user.Language) sess.SetTheme(user.Theme) diff --git a/ui/oauth2_callback.go b/ui/oauth2_callback.go index 6fc5a428..cd033f86 100644 --- a/ui/oauth2_callback.go +++ b/ui/oauth2_callback.go @@ -103,12 +103,11 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) { return } - user = model.NewUser() - user.Username = profile.Username - user.IsAdmin = false - authProvider.PopulateUserWithProfileID(user, profile) + userCreationRequest := &model.UserCreationRequest{Username: profile.Username} + authProvider.PopulateUserCreationWithProfileID(userCreationRequest, profile) - if err := h.store.CreateUser(user); err != nil { + user, err = h.store.CreateUser(userCreationRequest) + if err != nil { html.ServerError(w, r, err) return } diff --git a/ui/settings_update.go b/ui/settings_update.go index 468917ef..8d1193f2 100644 --- a/ui/settings_update.go +++ b/ui/settings_update.go @@ -16,6 +16,7 @@ import ( "miniflux.app/ui/form" "miniflux.app/ui/session" "miniflux.app/ui/view" + "miniflux.app/validator" ) func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) { @@ -51,8 +52,18 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - if h.store.AnotherUserExists(loggedUser.ID, settingsForm.Username) { - view.Set("errorMessage", "error.user_already_exists") + userModificationRequest := &model.UserModificationRequest{ + Username: model.OptionalString(settingsForm.Username), + Password: model.OptionalString(settingsForm.Password), + Theme: model.OptionalString(settingsForm.Theme), + Language: model.OptionalString(settingsForm.Language), + Timezone: model.OptionalString(settingsForm.Timezone), + EntryDirection: model.OptionalString(settingsForm.EntryDirection), + EntriesPerPage: model.OptionalInt(settingsForm.EntriesPerPage), + } + + if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil { + view.Set("errorMessage", validationErr.TranslationKey) html.OK(w, r, view.Render("settings")) return } diff --git a/ui/static_manifest.go b/ui/static_manifest.go index 5c9183d7..8b126b9f 100644 --- a/ui/static_manifest.go +++ b/ui/static_manifest.go @@ -54,9 +54,9 @@ func (h *handler) showWebManifest(w http.ResponseWriter, r *http.Request) { ThemeColor: themeColor, BackgroundColor: themeColor, Icons: []webManifestIcon{ - webManifestIcon{Source: route.Path(h.router, "appIcon", "filename", "icon-120.png"), Sizes: "120x120", Type: "image/png"}, - webManifestIcon{Source: route.Path(h.router, "appIcon", "filename", "icon-192.png"), Sizes: "192x192", Type: "image/png"}, - webManifestIcon{Source: route.Path(h.router, "appIcon", "filename", "icon-512.png"), Sizes: "512x512", Type: "image/png"}, + {Source: route.Path(h.router, "appIcon", "filename", "icon-120.png"), Sizes: "120x120", Type: "image/png"}, + {Source: route.Path(h.router, "appIcon", "filename", "icon-192.png"), Sizes: "192x192", Type: "image/png"}, + {Source: route.Path(h.router, "appIcon", "filename", "icon-512.png"), Sizes: "512x512", Type: "image/png"}, }, ShareTarget: webManifestShareTarget{ Action: route.Path(h.router, "bookmarklet"), diff --git a/ui/user_save.go b/ui/user_save.go index 01d26deb..45b9e84a 100644 --- a/ui/user_save.go +++ b/ui/user_save.go @@ -11,9 +11,11 @@ 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) saveUser(w http.ResponseWriter, r *http.Request) { @@ -50,8 +52,19 @@ func (h *handler) saveUser(w http.ResponseWriter, r *http.Request) { return } - newUser := userForm.ToUser() - if err := h.store.CreateUser(newUser); err != nil { + userCreationRequest := &model.UserCreationRequest{ + Username: userForm.Username, + Password: userForm.Password, + IsAdmin: userForm.IsAdmin, + } + + if validationErr := validator.ValidateUserCreationWithPassword(h.store, userCreationRequest); validationErr != nil { + view.Set("errorMessage", validationErr.TranslationKey) + html.OK(w, r, view.Render("create_user")) + return + } + + if _, err := h.store.CreateUser(userCreationRequest); err != nil { logger.Error("[UI:SaveUser] %v", err) view.Set("errorMessage", "error.unable_to_create_user") html.OK(w, r, view.Render("create_user")) diff --git a/ui/user_update.go b/ui/user_update.go index 678b737e..8d6be865 100644 --- a/ui/user_update.go +++ b/ui/user_update.go @@ -17,13 +17,13 @@ import ( ) func (h *handler) updateUser(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 } - if !user.IsAdmin { + if !loggedUser.IsAdmin { html.Forbidden(w, r) return } @@ -45,9 +45,9 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) { sess := session.New(h.store, request.SessionID(r)) view := view.New(h.tpl, r, sess) view.Set("menu", "settings") - 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("selected_user", selectedUser) view.Set("form", userForm) diff --git a/validator/user.go b/validator/user.go new file mode 100644 index 00000000..84fc824a --- /dev/null +++ b/validator/user.go @@ -0,0 +1,126 @@ +// 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/locale" + "miniflux.app/model" + "miniflux.app/storage" +) + +// ValidateUserCreationWithPassword validates user creation with a password. +func ValidateUserCreationWithPassword(store *storage.Storage, request *model.UserCreationRequest) *ValidationError { + if request.Username == "" { + return NewValidationError("error.user_mandatory_fields") + } + + if store.UserExists(request.Username) { + return NewValidationError("error.user_already_exists") + } + + if err := validatePassword(request.Password); err != nil { + return err + } + + return nil +} + +// ValidateUserModification validates user modifications. +func ValidateUserModification(store *storage.Storage, userID int64, changes *model.UserModificationRequest) *ValidationError { + if changes.Username != nil { + if *changes.Username == "" { + return NewValidationError("error.user_mandatory_fields") + } else if store.AnotherUserExists(userID, *changes.Username) { + return NewValidationError("error.user_already_exists") + } + } + + if changes.Password != nil { + if err := validatePassword(*changes.Password); err != nil { + return err + } + } + + if changes.Theme != nil { + if err := validateTheme(*changes.Theme); err != nil { + return err + } + } + + if changes.Language != nil { + if err := validateLanguage(*changes.Language); err != nil { + return err + } + } + + if changes.Timezone != nil { + if err := validateTimezone(store, *changes.Timezone); err != nil { + return err + } + } + + if changes.EntryDirection != nil { + if err := validateEntryDirection(*changes.EntryDirection); err != nil { + return err + } + } + + if changes.EntriesPerPage != nil { + if err := validateEntriesPerPage(*changes.EntriesPerPage); err != nil { + return err + } + } + + return nil +} + +func validatePassword(password string) *ValidationError { + if len(password) < 6 { + return NewValidationError("error.password_min_length") + } + return nil +} + +func validateTheme(theme string) *ValidationError { + themes := model.Themes() + if _, found := themes[theme]; !found { + return NewValidationError("error.invalid_theme") + } + return nil +} + +func validateLanguage(language string) *ValidationError { + languages := locale.AvailableLanguages() + if _, found := languages[language]; !found { + return NewValidationError("error.invalid_language") + } + return nil +} + +func validateTimezone(store *storage.Storage, timezone string) *ValidationError { + timezones, err := store.Timezones() + if err != nil { + return NewValidationError(err.Error()) + } + + if _, found := timezones[timezone]; !found { + return NewValidationError("error.invalid_timezone") + } + return nil +} + +func validateEntryDirection(direction string) *ValidationError { + if direction != "asc" && direction != "desc" { + return NewValidationError("error.invalid_entry_direction") + } + return nil +} + +func validateEntriesPerPage(entriesPerPage int) *ValidationError { + if entriesPerPage < 1 { + return NewValidationError("error.entries_per_page_invalid") + } + return nil +} diff --git a/validator/validator.go b/validator/validator.go new file mode 100644 index 00000000..0fd8b07b --- /dev/null +++ b/validator/validator.go @@ -0,0 +1,29 @@ +// 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 ( + "errors" + + "miniflux.app/locale" +) + +// ValidationError represents a validation error. +type ValidationError struct { + TranslationKey string +} + +// NewValidationError initializes a validation error. +func NewValidationError(translationKey string) *ValidationError { + return &ValidationError{TranslationKey: translationKey} +} + +func (v *ValidationError) String() string { + return locale.NewPrinter("en_US").Printf(v.TranslationKey) +} + +func (v *ValidationError) Error() error { + return errors.New(v.String()) +}