diff --git a/client/client.go b/client/client.go index 5cdbd1b3..192f3874 100644 --- a/client/client.go +++ b/client/client.go @@ -180,6 +180,45 @@ func (c *Client) DeleteUser(userID int64) error { return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID)) } +// APIKeys returns all API keys for the authenticated user. +func (c *Client) APIKeys() (APIKeys, error) { + body, err := c.request.Get("/v1/api-keys") + if err != nil { + return nil, err + } + defer body.Close() + + var apiKeys APIKeys + if err := json.NewDecoder(body).Decode(&apiKeys); err != nil { + return nil, fmt.Errorf("miniflux: response error (%v)", err) + } + + return apiKeys, nil +} + +// CreateAPIKey creates a new API key for the authenticated user. +func (c *Client) CreateAPIKey(description string) (*APIKey, error) { + body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{ + Description: description, + }) + if err != nil { + return nil, err + } + defer body.Close() + + var apiKey *APIKey + if err := json.NewDecoder(body).Decode(&apiKey); err != nil { + return nil, fmt.Errorf("miniflux: response error (%v)", err) + } + + return apiKey, nil +} + +// DeleteAPIKey removes an API key for the authenticated user. +func (c *Client) DeleteAPIKey(apiKeyID int64) error { + return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID)) +} + // MarkAllAsRead marks all unread entries as read for a given user. func (c *Client) MarkAllAsRead(userID int64) error { _, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil) diff --git a/client/model.go b/client/model.go index ed9b4994..1e064d26 100644 --- a/client/model.go +++ b/client/model.go @@ -327,6 +327,24 @@ type VersionResponse struct { OS string `json:"os"` } +// APIKey represents an application API key. +type APIKey struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + Description string `json:"description"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` +} + +// APIKeys represents a collection of API keys. +type APIKeys []*APIKey + +// APIKeyCreationRequest represents the request to create an API key. +type APIKeyCreationRequest struct { + Description string `json:"description"` +} + func SetOptionalField[T any](value T) *T { return &value } diff --git a/internal/api/api.go b/internal/api/api.go index c717e6cb..f2945487 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -76,6 +76,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut) sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet) sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet) + sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost) + sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet) + sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete) } func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/api_integration_test.go b/internal/api/api_integration_test.go index 53f4d367..2396982e 100644 --- a/internal/api/api_integration_test.go +++ b/internal/api/api_integration_test.go @@ -729,6 +729,116 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) { } } +func TestAPIKeysEndpoint(t *testing.T) { + testConfig := newIntegrationTestConfig() + if !testConfig.isConfigured() { + t.Skip(skipIntegrationTestsMessage) + } + + adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword) + regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false) + if err != nil { + t.Fatal(err) + } + defer adminClient.DeleteUser(regularTestUser.ID) + + regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword) + apiKeys, err := regularUserClient.APIKeys() + if err != nil { + t.Fatal(err) + } + + if len(apiKeys) != 0 { + t.Fatalf(`Expected no API keys, got %d`, len(apiKeys)) + } + + // Create an API key for the user. + apiKey, err := regularUserClient.CreateAPIKey("Test API Key") + if err != nil { + t.Fatal(err) + } + if apiKey.ID == 0 { + t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID) + } + if apiKey.UserID != regularTestUser.ID { + t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID) + } + if apiKey.Token == "" { + t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token) + } + if apiKey.Description != "Test API Key" { + t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description) + } + + // Create a duplicate API key with the same description. + if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil { + t.Fatal(`Creating a duplicate API key with the same description should raise an error`) + } + + // Fetch the API keys again. + apiKeys, err = regularUserClient.APIKeys() + if err != nil { + t.Fatal(err) + } + if len(apiKeys) != 1 { + t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys)) + } + if apiKeys[0].ID != apiKey.ID { + t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID) + } + if apiKeys[0].UserID != regularTestUser.ID { + t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID) + } + if apiKeys[0].Token != apiKey.Token { + t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token) + } + if apiKeys[0].Description != "Test API Key" { + t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description) + } + + // Create a new client using the API key. + apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token) + + // Fetch the user using the API key client. + user, err := apiKeyClient.Me() + if err != nil { + t.Fatal(err) + } + + // Verify the user matches the regular test user. + if user.ID != regularTestUser.ID { + t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID) + } + + // Delete the API key. + if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil { + t.Fatal(err) + } + + // Verify the API key is deleted. + apiKeys, err = regularUserClient.APIKeys() + if err != nil { + t.Fatal(err) + } + if len(apiKeys) != 0 { + t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys)) + } + + // Try to delete the API key again, it should return an error. + err = regularUserClient.DeleteAPIKey(apiKey.ID) + if err == nil { + t.Fatal(`Deleting a non-existent API key should raise an error`) + } + if !errors.Is(err, miniflux.ErrNotFound) { + t.Fatalf(`Expected "not found" error, got %v`, err) + } + + // Try to create an API key with an empty description. + if _, err := regularUserClient.CreateAPIKey(""); err == nil { + t.Fatal(`Creating an API key with an empty description should raise an error`) + } +} + func TestMarkUserAsReadEndpoint(t *testing.T) { testConfig := newIntegrationTestConfig() if !testConfig.isConfigured() { diff --git a/internal/api/api_key.go b/internal/api/api_key.go new file mode 100644 index 00000000..13ffddaa --- /dev/null +++ b/internal/api/api_key.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package api // import "miniflux.app/v2/internal/api" + +import ( + json_parser "encoding/json" + "errors" + "net/http" + + "miniflux.app/v2/internal/http/request" + "miniflux.app/v2/internal/http/response/json" + "miniflux.app/v2/internal/model" + "miniflux.app/v2/internal/storage" + "miniflux.app/v2/internal/validator" +) + +func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) { + userID := request.UserID(r) + + var apiKeyCreationRequest model.APIKeyCreationRequest + if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil { + json.BadRequest(w, r, err) + return + } + + if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil { + json.BadRequest(w, r, validationErr.Error()) + return + } + + apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description) + if err != nil { + json.ServerError(w, r, err) + return + } + + json.Created(w, r, apiKey) +} + +func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) { + userID := request.UserID(r) + apiKeys, err := h.store.APIKeys(userID) + if err != nil { + json.ServerError(w, r, err) + return + } + json.OK(w, r, apiKeys) +} + +func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) { + userID := request.UserID(r) + apiKeyID := request.RouteInt64Param(r, "apiKeyID") + + if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil { + if errors.Is(err, storage.ErrAPIKeyNotFound) { + json.NotFound(w, r) + return + } + json.ServerError(w, r, err) + return + } + json.NoContent(w, r) +} diff --git a/internal/model/api_key.go b/internal/model/api_key.go index c623c9b8..f6609902 100644 --- a/internal/model/api_key.go +++ b/internal/model/api_key.go @@ -5,28 +5,22 @@ package model // import "miniflux.app/v2/internal/model" import ( "time" - - "miniflux.app/v2/internal/crypto" ) // APIKey represents an application API key. type APIKey struct { - ID int64 - UserID int64 - Token string - Description string - LastUsedAt *time.Time - CreatedAt time.Time -} - -// NewAPIKey initializes a new APIKey. -func NewAPIKey(userID int64, description string) *APIKey { - return &APIKey{ - UserID: userID, - Token: crypto.GenerateRandomString(32), - Description: description, - } + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + Description string `json:"description"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` } // APIKeys represents a collection of API Key. type APIKeys []*APIKey + +// APIKeyCreationRequest represents the request to create a new API Key. +type APIKeyCreationRequest struct { + Description string `json:"description"` +} diff --git a/internal/storage/api_key.go b/internal/storage/api_key.go index 7004a3a7..66a6fa55 100644 --- a/internal/storage/api_key.go +++ b/internal/storage/api_key.go @@ -6,9 +6,12 @@ package storage // import "miniflux.app/v2/internal/storage" import ( "fmt" + "miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/model" ) +var ErrAPIKeyNotFound = fmt.Errorf("store: API Key not found") + // APIKeyExists checks if an API Key with the same description exists. func (s *Storage) APIKeyExists(userID int64, description string) bool { var result bool @@ -66,37 +69,50 @@ func (s *Storage) APIKeys(userID int64) (model.APIKeys, error) { } // CreateAPIKey inserts a new API key. -func (s *Storage) CreateAPIKey(apiKey *model.APIKey) error { +func (s *Storage) CreateAPIKey(userID int64, description string) (*model.APIKey, error) { query := ` INSERT INTO api_keys (user_id, token, description) VALUES ($1, $2, $3) RETURNING - id, created_at + id, user_id, token, description, last_used_at, created_at ` + var apiKey model.APIKey err := s.db.QueryRow( query, - apiKey.UserID, - apiKey.Token, - apiKey.Description, + userID, + crypto.GenerateRandomStringHex(32), + description, ).Scan( &apiKey.ID, + &apiKey.UserID, + &apiKey.Token, + &apiKey.Description, + &apiKey.LastUsedAt, &apiKey.CreatedAt, ) if err != nil { - return fmt.Errorf(`store: unable to create category: %v`, err) + return nil, fmt.Errorf(`store: unable to create API Key: %v`, err) } - return nil + return &apiKey, nil } -// RemoveAPIKey deletes an API Key. -func (s *Storage) RemoveAPIKey(userID, keyID int64) error { - query := `DELETE FROM api_keys WHERE id = $1 AND user_id = $2` - _, err := s.db.Exec(query, keyID, userID) +// DeleteAPIKey deletes an API Key. +func (s *Storage) DeleteAPIKey(userID, keyID int64) error { + result, err := s.db.Exec(`DELETE FROM api_keys WHERE id = $1 AND user_id = $2`, keyID, userID) if err != nil { - return fmt.Errorf(`store: unable to remove this API Key: %v`, err) + return fmt.Errorf(`store: unable to delete this API Key: %v`, err) + } + + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf(`store: unable to delete this API Key: %v`, err) + } + + if count == 0 { + return ErrAPIKeyNotFound } return nil diff --git a/internal/template/templates/views/api_keys.html b/internal/template/templates/views/api_keys.html index 956ca58b..da2884a9 100644 --- a/internal/template/templates/views/api_keys.html +++ b/internal/template/templates/views/api_keys.html @@ -44,7 +44,7 @@ data-label-yes="{{ t "confirm.yes" }}" data-label-no="{{ t "confirm.no" }}" data-label-loading="{{ t "confirm.loading" }}" - data-url="{{ route "removeAPIKey" "keyID" .ID }}">{{ t "action.remove" }} + data-url="{{ route "deleteAPIKey" "keyID" .ID }}">{{ t "action.remove" }} diff --git a/internal/ui/api_key_remove.go b/internal/ui/api_key_remove.go index 3730240c..85611200 100644 --- a/internal/ui/api_key_remove.go +++ b/internal/ui/api_key_remove.go @@ -11,10 +11,9 @@ import ( "miniflux.app/v2/internal/http/route" ) -func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) { keyID := request.RouteInt64Param(r, "keyID") - err := h.store.RemoveAPIKey(request.UserID(r), keyID) - if err != nil { + if err := h.store.DeleteAPIKey(request.UserID(r), keyID); err != nil { html.ServerError(w, r, err) return } diff --git a/internal/ui/api_key_save.go b/internal/ui/api_key_save.go index 34773c30..c28c70a4 100644 --- a/internal/ui/api_key_save.go +++ b/internal/ui/api_key_save.go @@ -9,44 +9,39 @@ import ( "miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/route" - "miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/model" "miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/view" + "miniflux.app/v2/internal/validator" ) func (h *handler) saveAPIKey(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 } apiKeyForm := form.NewAPIKeyForm(r) + apiKeyCreationRequest := &model.APIKeyCreationRequest{ + Description: apiKeyForm.Description, + } - sess := session.New(h.store, request.SessionID(r)) - view := view.New(h.tpl, r, sess) - view.Set("form", apiKeyForm) - view.Set("menu", "settings") - view.Set("user", user) - view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) - view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) - - if validationErr := apiKeyForm.Validate(); validationErr != nil { - view.Set("errorMessage", validationErr.Translate(user.Language)) + if validationErr := validator.ValidateAPIKeyCreation(h.store, loggedUser.ID, apiKeyCreationRequest); validationErr != nil { + sess := session.New(h.store, request.SessionID(r)) + view := view.New(h.tpl, r, sess) + view.Set("form", apiKeyForm) + view.Set("menu", "settings") + view.Set("user", loggedUser) + view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID)) + view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID)) + view.Set("errorMessage", validationErr.Translate(loggedUser.Language)) html.OK(w, r, view.Render("create_api_key")) return } - if h.store.APIKeyExists(user.ID, apiKeyForm.Description) { - view.Set("errorMessage", locale.NewLocalizedError("error.api_key_already_exists").Translate(user.Language)) - html.OK(w, r, view.Render("create_api_key")) - return - } - - apiKey := model.NewAPIKey(user.ID, apiKeyForm.Description) - if err = h.store.CreateAPIKey(apiKey); err != nil { + if _, err = h.store.CreateAPIKey(loggedUser.ID, apiKeyCreationRequest.Description); err != nil { html.ServerError(w, r, err) return } diff --git a/internal/ui/form/api_key.go b/internal/ui/form/api_key.go index b87322a5..ab0ec9e4 100644 --- a/internal/ui/form/api_key.go +++ b/internal/ui/form/api_key.go @@ -5,8 +5,7 @@ package form // import "miniflux.app/v2/internal/ui/form" import ( "net/http" - - "miniflux.app/v2/internal/locale" + "strings" ) // APIKeyForm represents the API Key form. @@ -14,18 +13,9 @@ type APIKeyForm struct { Description string } -// Validate makes sure the form values are valid. -func (a APIKeyForm) Validate() *locale.LocalizedError { - if a.Description == "" { - return locale.NewLocalizedError("error.fields_mandatory") - } - - return nil -} - // NewAPIKeyForm returns a new APIKeyForm. func NewAPIKeyForm(r *http.Request) *APIKeyForm { return &APIKeyForm{ - Description: r.FormValue("description"), + Description: strings.TrimSpace(r.FormValue("description")), } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index f6a55c74..32e39529 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -136,7 +136,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { // API Keys pages. uiRouter.HandleFunc("/keys", handler.showAPIKeysPage).Name("apiKeys").Methods(http.MethodGet) - uiRouter.HandleFunc("/keys/{keyID}/remove", handler.removeAPIKey).Name("removeAPIKey").Methods(http.MethodPost) + uiRouter.HandleFunc("/keys/{keyID}/delete", handler.deleteAPIKey).Name("deleteAPIKey").Methods(http.MethodPost) uiRouter.HandleFunc("/keys/create", handler.showCreateAPIKeyPage).Name("createAPIKey").Methods(http.MethodGet) uiRouter.HandleFunc("/keys/save", handler.saveAPIKey).Name("saveAPIKey").Methods(http.MethodPost) diff --git a/internal/validator/api_key.go b/internal/validator/api_key.go new file mode 100644 index 00000000..99d904b8 --- /dev/null +++ b/internal/validator/api_key.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package validator // import "miniflux.app/v2/internal/validator" + +import ( + "miniflux.app/v2/internal/locale" + "miniflux.app/v2/internal/model" + "miniflux.app/v2/internal/storage" +) + +func ValidateAPIKeyCreation(store *storage.Storage, userID int64, request *model.APIKeyCreationRequest) *locale.LocalizedError { + if request.Description == "" { + return locale.NewLocalizedError("error.fields_mandatory") + } + + if store.APIKeyExists(userID, request.Description) { + return locale.NewLocalizedError("error.api_key_already_exists") + } + + return nil +}