Просмотр исходного кода

feat(api): add new endpoints to manage API keys

Frédéric Guillot 10 месяцев назад
Родитель
Сommit
bfd8860398

+ 39 - 0
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)

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

+ 3 - 0
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) {

+ 110 - 0
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() {

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

+ 11 - 17
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"`
+}

+ 28 - 12
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 delete this API Key: %v`, err)
+	}
+
+	count, err := result.RowsAffected()
 	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)
+	}
+
+	if count == 0 {
+		return ErrAPIKeyNotFound
 	}
 
 	return nil

+ 1 - 1
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" }}</a>
+                data-url="{{ route "deleteAPIKey" "keyID" .ID }}">{{ t "action.remove" }}</a>
         </td>
     </tr>
     </table>

+ 2 - 3
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
 	}

+ 14 - 19
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)
-
-	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))
-		html.OK(w, r, view.Render("create_api_key"))
-		return
+	apiKeyCreationRequest := &model.APIKeyCreationRequest{
+		Description: apiKeyForm.Description,
 	}
 
-	if h.store.APIKeyExists(user.ID, apiKeyForm.Description) {
-		view.Set("errorMessage", locale.NewLocalizedError("error.api_key_already_exists").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
 	}
 
-	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
 	}

+ 2 - 12
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")),
 	}
 }

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

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