1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-06-27 16:36:00 +00:00

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

This commit is contained in:
Frédéric Guillot 2025-05-25 15:37:37 -07:00
parent ebd65da3b6
commit bfd8860398
13 changed files with 316 additions and 66 deletions

View file

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

View file

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

View file

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

View file

@ -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
internal/api/api_key.go Normal file
View file

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

View file

@ -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"`
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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