Browse Source

refactor(api): remove dependency on gorilla/mux

Frédéric Guillot 3 weeks ago
parent
commit
cc528b640b

+ 60 - 64
internal/api/api.go

@@ -8,74 +8,70 @@ import (
 
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/worker"
-
-	"github.com/gorilla/mux"
 )
 
 type handler struct {
-	store  *storage.Storage
-	pool   *worker.Pool
-	router *mux.Router
+	store *storage.Storage
+	pool  *worker.Pool
 }
 
-// Serve declares API routes for the application.
-func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
-	handler := &handler{store, pool, router}
-
-	sr := router.PathPrefix("/v1").Subrouter()
+// NewHandler returns an http.Handler that handles API v1 calls.
+// The returned handler expects the base path to be stripped from the request URL.
+func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
+	handler := &handler{store: store, pool: pool}
 	middleware := newMiddleware(store)
-	sr.Use(middleware.handleCORS)
-	sr.Use(middleware.apiKeyAuth)
-	sr.Use(middleware.basicAuth)
-	sr.Methods(http.MethodOptions)
-	sr.HandleFunc("/users", handler.createUserHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/users", handler.usersHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByIDHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUserHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUserHandler).Methods(http.MethodDelete)
-	sr.HandleFunc("/users/{userID:[0-9]+}/mark-all-as-read", handler.markUserAsReadHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/users/{username}", handler.userByUsernameHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/me", handler.currentUserHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/categories", handler.createCategoryHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/categories", handler.getCategoriesHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/categories/{categoryID}", handler.updateCategoryHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/categories/{categoryID}", handler.removeCategoryHandler).Methods(http.MethodDelete)
-	sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsReadHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/categories/{categoryID}/feeds", handler.getCategoryFeedsHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/categories/{categoryID}/refresh", handler.refreshCategoryHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/categories/{categoryID}/entries", handler.getCategoryEntriesHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntryHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/discover", handler.discoverSubscriptionsHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/feeds", handler.createFeedHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/feeds", handler.getFeedsHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/feeds/counters", handler.fetchCountersHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/feeds/refresh", handler.refreshAllFeedsHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeedHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/feeds/{feedID}", handler.getFeedHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/feeds/{feedID}", handler.updateFeedHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/feeds/{feedID}", handler.removeFeedHandler).Methods(http.MethodDelete)
-	sr.HandleFunc("/feeds/{feedID}/icon", handler.getIconByFeedIDHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/feeds/{feedID}/mark-all-as-read", handler.markFeedAsReadHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/export", handler.exportFeedsHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/import", handler.importFeedsHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntriesHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntryHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/entries", handler.getEntriesHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/entries", handler.setEntryStatusHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/entries/{entryID}", handler.getEntryHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/entries/{entryID}", handler.updateEntryHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarredHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarredHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/entries/{entryID}/save", handler.saveEntryHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContentHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/flush-history", handler.flushHistoryHandler).Methods(http.MethodPut, http.MethodDelete)
-	sr.HandleFunc("/icons/{iconID}", handler.getIconByIconIDHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByIDHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByIDHandler).Methods(http.MethodPut)
-	sr.HandleFunc("/integrations/status", handler.getIntegrationsStatusHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/api-keys", handler.createAPIKeyHandler).Methods(http.MethodPost)
-	sr.HandleFunc("/api-keys", handler.getAPIKeysHandler).Methods(http.MethodGet)
-	sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKeyHandler).Methods(http.MethodDelete)
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("POST /v1/users", handler.createUserHandler)
+	mux.HandleFunc("GET /v1/users", handler.usersHandler)
+	mux.HandleFunc("GET /v1/users/{identifier}", handler.dispatchUserLookupHandler)
+	mux.HandleFunc("PUT /v1/users/{userID}", handler.updateUserHandler)
+	mux.HandleFunc("DELETE /v1/users/{userID}", handler.removeUserHandler)
+	mux.HandleFunc("PUT /v1/users/{userID}/mark-all-as-read", handler.markUserAsReadHandler)
+	mux.HandleFunc("GET /v1/me", handler.currentUserHandler)
+	mux.HandleFunc("POST /v1/categories", handler.createCategoryHandler)
+	mux.HandleFunc("GET /v1/categories", handler.getCategoriesHandler)
+	mux.HandleFunc("PUT /v1/categories/{categoryID}", handler.updateCategoryHandler)
+	mux.HandleFunc("DELETE /v1/categories/{categoryID}", handler.removeCategoryHandler)
+	mux.HandleFunc("PUT /v1/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsReadHandler)
+	mux.HandleFunc("GET /v1/categories/{categoryID}/feeds", handler.getCategoryFeedsHandler)
+	mux.HandleFunc("PUT /v1/categories/{categoryID}/refresh", handler.refreshCategoryHandler)
+	mux.HandleFunc("GET /v1/categories/{categoryID}/entries", handler.getCategoryEntriesHandler)
+	mux.HandleFunc("GET /v1/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntryHandler)
+	mux.HandleFunc("POST /v1/discover", handler.discoverSubscriptionsHandler)
+	mux.HandleFunc("POST /v1/feeds", handler.createFeedHandler)
+	mux.HandleFunc("GET /v1/feeds", handler.getFeedsHandler)
+	mux.HandleFunc("GET /v1/feeds/counters", handler.fetchCountersHandler)
+	mux.HandleFunc("PUT /v1/feeds/refresh", handler.refreshAllFeedsHandler)
+	mux.HandleFunc("PUT /v1/feeds/{feedID}/refresh", handler.refreshFeedHandler)
+	mux.HandleFunc("GET /v1/feeds/{feedID}", handler.getFeedHandler)
+	mux.HandleFunc("PUT /v1/feeds/{feedID}", handler.updateFeedHandler)
+	mux.HandleFunc("DELETE /v1/feeds/{feedID}", handler.removeFeedHandler)
+	mux.HandleFunc("GET /v1/feeds/{feedID}/icon", handler.getIconByFeedIDHandler)
+	mux.HandleFunc("PUT /v1/feeds/{feedID}/mark-all-as-read", handler.markFeedAsReadHandler)
+	mux.HandleFunc("GET /v1/export", handler.exportFeedsHandler)
+	mux.HandleFunc("POST /v1/import", handler.importFeedsHandler)
+	mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
+	mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
+	mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
+	mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
+	mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
+	mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
+	mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
+	mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
+	mux.HandleFunc("PUT /v1/entries/{entryID}/star", handler.toggleStarredHandler)
+	mux.HandleFunc("POST /v1/entries/{entryID}/save", handler.saveEntryHandler)
+	mux.HandleFunc("GET /v1/entries/{entryID}/fetch-content", handler.fetchContentHandler)
+	mux.HandleFunc("PUT /v1/flush-history", handler.flushHistoryHandler)
+	mux.HandleFunc("DELETE /v1/flush-history", handler.flushHistoryHandler)
+	mux.HandleFunc("GET /v1/icons/{iconID}", handler.getIconByIconIDHandler)
+	mux.HandleFunc("GET /v1/enclosures/{enclosureID}", handler.getEnclosureByIDHandler)
+	mux.HandleFunc("PUT /v1/enclosures/{enclosureID}", handler.updateEnclosureByIDHandler)
+	mux.HandleFunc("GET /v1/integrations/status", handler.getIntegrationsStatusHandler)
+	mux.HandleFunc("GET /v1/version", handler.versionHandler)
+	mux.HandleFunc("POST /v1/api-keys", handler.createAPIKeyHandler)
+	mux.HandleFunc("GET /v1/api-keys", handler.getAPIKeysHandler)
+	mux.HandleFunc("DELETE /v1/api-keys/{apiKeyID}", handler.deleteAPIKeyHandler)
+
+	return middleware.handleCORS(middleware.apiKeyAuth(middleware.basicAuth(mux)))
 }

+ 4 - 0
internal/api/api_key_handlers.go

@@ -51,6 +51,10 @@ func (h *handler) getAPIKeysHandler(w http.ResponseWriter, r *http.Request) {
 func (h *handler) deleteAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	apiKeyID := request.RouteInt64Param(r, "apiKeyID")
+	if apiKeyID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid API key ID"))
+		return
+	}
 
 	if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
 		if errors.Is(err, storage.ErrAPIKeyNotFound) {

+ 110 - 0
internal/api/api_test.go

@@ -0,0 +1,110 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package api // import "miniflux.app/v2/internal/api"
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"runtime"
+	"testing"
+
+	"miniflux.app/v2/internal/version"
+)
+
+func TestNewHandlerHandlesOptionsRequests(t *testing.T) {
+	handler := NewHandler(nil, nil)
+
+	r := httptest.NewRequest(http.MethodOptions, "/v1/users", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, r)
+
+	if got := w.Code; got != http.StatusOK {
+		t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
+	}
+
+	if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+		t.Fatalf(`Unexpected Access-Control-Allow-Origin header, got %q`, got)
+	}
+
+	if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
+		t.Fatalf(`Unexpected Access-Control-Allow-Methods header, got %q`, got)
+	}
+}
+
+func TestVersionHandler(t *testing.T) {
+	h := &handler{}
+	r := httptest.NewRequest(http.MethodGet, "/v1/version", nil)
+	w := httptest.NewRecorder()
+
+	h.versionHandler(w, r)
+
+	if got := w.Code; got != http.StatusOK {
+		t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
+	}
+
+	if got := w.Header().Get("Content-Type"); got != "application/json" {
+		t.Fatalf(`Unexpected Content-Type header, got %q`, got)
+	}
+
+	var responseBody versionResponse
+	if err := json.NewDecoder(w.Body).Decode(&responseBody); err != nil {
+		t.Fatalf("Unexpected JSON decoding error: %v", err)
+	}
+
+	if responseBody.Version != version.Version {
+		t.Fatalf(`Unexpected version, got %q instead of %q`, responseBody.Version, version.Version)
+	}
+
+	if responseBody.Commit != version.Commit {
+		t.Fatalf(`Unexpected commit, got %q instead of %q`, responseBody.Commit, version.Commit)
+	}
+
+	if responseBody.BuildDate != version.BuildDate {
+		t.Fatalf(`Unexpected build date, got %q instead of %q`, responseBody.BuildDate, version.BuildDate)
+	}
+
+	if responseBody.GoVersion != runtime.Version() {
+		t.Fatalf(`Unexpected Go version, got %q instead of %q`, responseBody.GoVersion, runtime.Version())
+	}
+
+	if responseBody.Compiler != runtime.Compiler {
+		t.Fatalf(`Unexpected compiler, got %q instead of %q`, responseBody.Compiler, runtime.Compiler)
+	}
+
+	if responseBody.Arch != runtime.GOARCH {
+		t.Fatalf(`Unexpected architecture, got %q instead of %q`, responseBody.Arch, runtime.GOARCH)
+	}
+
+	if responseBody.OS != runtime.GOOS {
+		t.Fatalf(`Unexpected OS, got %q instead of %q`, responseBody.OS, runtime.GOOS)
+	}
+}
+
+func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
+	scenarios := []struct {
+		name   string
+		prefix string
+		path   string
+	}{
+		{name: "empty base path", prefix: "", path: "/v1/users"},
+		{name: "non empty base path", prefix: "/base", path: "/base/v1/users"},
+	}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.name, func(t *testing.T) {
+			handler := http.StripPrefix(scenario.prefix, NewHandler(nil, nil))
+
+			r := httptest.NewRequest(http.MethodOptions, scenario.path, nil)
+			w := httptest.NewRecorder()
+
+			handler.ServeHTTP(w, r)
+
+			if got := w.Code; got != http.StatusOK {
+				t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
+			}
+		})
+	}
+}

+ 21 - 0
internal/api/category_handlers.go

@@ -5,6 +5,7 @@ package api // import "miniflux.app/v2/internal/api"
 
 import (
 	json_parser "encoding/json"
+	"errors"
 	"log/slog"
 	"net/http"
 	"time"
@@ -41,7 +42,12 @@ func (h *handler) createCategoryHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
+
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
 
 	category, err := h.store.Category(userID, categoryID)
 	if err != nil {
@@ -77,7 +83,12 @@ func (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
+
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
 
 	category, err := h.store.Category(userID, categoryID)
 	if err != nil {
@@ -118,7 +129,12 @@ func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
+
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
 
 	if !h.store.CategoryIDExists(userID, categoryID) {
 		response.JSONNotFound(w, r)
@@ -135,7 +151,12 @@ func (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
+
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
 
 	batchBuilder := h.store.NewBatchBuilder()
 	batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())

+ 9 - 0
internal/api/enclosure_handlers.go

@@ -5,6 +5,7 @@ package api // import "miniflux.app/v2/internal/api"
 
 import (
 	json_parser "encoding/json"
+	"errors"
 	"net/http"
 
 	"miniflux.app/v2/internal/config"
@@ -16,6 +17,10 @@ import (
 
 func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
 	enclosureID := request.RouteInt64Param(r, "enclosureID")
+	if enclosureID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
+		return
+	}
 
 	enclosure, err := h.store.GetEnclosure(enclosureID)
 	if err != nil {
@@ -41,6 +46,10 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
 
 func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
 	enclosureID := request.RouteInt64Param(r, "enclosureID")
+	if enclosureID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
+		return
+	}
 
 	var enclosureUpdateRequest model.EnclosureUpdateRequest
 	if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {

+ 53 - 2
internal/api/entry_handlers.go

@@ -44,7 +44,16 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
 
 func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
 	feedID := request.RouteInt64Param(r, "feedID")
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
+
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
 
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithFeedID(feedID)
@@ -56,7 +65,16 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request) {
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
+
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
 
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithCategoryID(categoryID)
@@ -68,6 +86,11 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
 
 func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
+
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithEntryID(entryID)
 	builder.WithoutStatus(model.EntryStatusRemoved)
@@ -77,11 +100,20 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) getFeedEntriesHandler(w http.ResponseWriter, r *http.Request) {
 	feedID := request.RouteInt64Param(r, "feedID")
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
+
 	h.findEntries(w, r, feedID, 0)
 }
 
 func (h *handler) getCategoryEntriesHandler(w http.ResponseWriter, r *http.Request) {
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
 	h.findEntries(w, r, 0, categoryID)
 }
 
@@ -194,6 +226,11 @@ func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
+
 	if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
 		response.JSONServerError(w, r, err)
 		return
@@ -204,6 +241,11 @@ func (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
+
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithEntryID(entryID)
 	builder.WithoutStatus(model.EntryStatusRemoved)
@@ -247,9 +289,13 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	loggedUserID := request.UserID(r)
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
 
+	loggedUserID := request.UserID(r)
 	entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
 	entryBuilder.WithEntryID(entryID)
 	entryBuilder.WithoutStatus(model.EntryStatusRemoved)
@@ -296,8 +342,8 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
-	feedID := request.RouteInt64Param(r, "feedID")
 
+	feedID := request.RouteInt64Param(r, "feedID")
 	if feedID <= 0 {
 		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
 		return
@@ -400,7 +446,12 @@ func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
 	loggedUserID := request.UserID(r)
+
 	entryID := request.RouteInt64Param(r, "entryID")
+	if entryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
+		return
+	}
 
 	entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
 	entryBuilder.WithEntryID(entryID)

+ 33 - 5
internal/api/feed_handlers.go

@@ -5,6 +5,7 @@ package api // import "miniflux.app/v2/internal/api"
 
 import (
 	json_parser "encoding/json"
+	"errors"
 	"log/slog"
 	"net/http"
 	"time"
@@ -52,8 +53,12 @@ func (h *handler) createFeedHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
 	feedID := request.RouteInt64Param(r, "feedID")
-	userID := request.UserID(r)
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
 
+	userID := request.UserID(r)
 	if !h.store.FeedExists(userID, feedID) {
 		response.JSONNotFound(w, r)
 		return
@@ -96,6 +101,12 @@ func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
+	feedID := request.RouteInt64Param(r, "feedID")
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
+
 	var feedModificationRequest model.FeedModificationRequest
 	if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
 		response.JSONBadRequest(w, r, err)
@@ -103,8 +114,6 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
 	}
 
 	userID := request.UserID(r)
-	feedID := request.RouteInt64Param(r, "feedID")
-
 	originalFeed, err := h.store.FeedByID(userID, feedID)
 	if err != nil {
 		response.JSONNotFound(w, r)
@@ -138,9 +147,14 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request) {
-	feedID := request.RouteInt64Param(r, "feedID")
 	userID := request.UserID(r)
 
+	feedID := request.RouteInt64Param(r, "feedID")
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
+
 	if !h.store.FeedExists(userID, feedID) {
 		response.JSONNotFound(w, r)
 		return
@@ -156,7 +170,12 @@ func (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) getCategoryFeedsHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
+
 	categoryID := request.RouteInt64Param(r, "categoryID")
+	if categoryID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid category ID"))
+		return
+	}
 
 	category, err := h.store.Category(userID, categoryID)
 	if err != nil {
@@ -200,6 +219,11 @@ func (h *handler) fetchCountersHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {
 	feedID := request.RouteInt64Param(r, "feedID")
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
+
 	feed, err := h.store.FeedByID(request.UserID(r), feedID)
 	if err != nil {
 		response.JSONServerError(w, r, err)
@@ -216,8 +240,12 @@ func (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) removeFeedHandler(w http.ResponseWriter, r *http.Request) {
 	feedID := request.RouteInt64Param(r, "feedID")
-	userID := request.UserID(r)
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
 
+	userID := request.UserID(r)
 	if !h.store.FeedExists(userID, feedID) {
 		response.JSONNotFound(w, r)
 		return

+ 9 - 0
internal/api/icon_handlers.go

@@ -4,6 +4,7 @@
 package api // import "miniflux.app/v2/internal/api"
 
 import (
+	"errors"
 	"net/http"
 
 	"miniflux.app/v2/internal/http/request"
@@ -12,6 +13,10 @@ import (
 
 func (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request) {
 	feedID := request.RouteInt64Param(r, "feedID")
+	if feedID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
+		return
+	}
 
 	icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
 	if err != nil {
@@ -33,6 +38,10 @@ func (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request) {
 	iconID := request.RouteInt64Param(r, "iconID")
+	if iconID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid icon ID"))
+		return
+	}
 
 	icon, err := h.store.IconByID(iconID)
 	if err != nil {

+ 0 - 0
internal/api/payload.go → internal/api/messages.go


+ 32 - 1
internal/api/user_handlers.go

@@ -52,6 +52,10 @@ func (h *handler) createUserHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.RouteInt64Param(r, "userID")
+	if userID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid user ID"))
+		return
+	}
 
 	var userModificationRequest model.UserModificationRequest
 	if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
@@ -98,6 +102,11 @@ func (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {
 
 func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.RouteInt64Param(r, "userID")
+	if userID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid user ID"))
+		return
+	}
+
 	if userID != request.UserID(r) {
 		response.JSONForbidden(w, r)
 		return
@@ -118,7 +127,6 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
 
 func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
-
 	if _, err := h.store.UserByID(userID); err != nil {
 		response.JSONNotFound(w, r)
 		return
@@ -145,6 +153,19 @@ func (h *handler) usersHandler(w http.ResponseWriter, r *http.Request) {
 	response.JSON(w, r, users)
 }
 
+func (h *handler) dispatchUserLookupHandler(w http.ResponseWriter, r *http.Request) {
+	identifier := request.RouteStringParam(r, "identifier")
+	userID := request.RouteInt64Param(r, "identifier")
+	if userID > 0 {
+		r.SetPathValue("userID", identifier)
+		h.userByIDHandler(w, r)
+		return
+	}
+
+	r.SetPathValue("username", identifier)
+	h.userByUsernameHandler(w, r)
+}
+
 func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
 	if !request.IsAdminUser(r) {
 		response.JSONForbidden(w, r)
@@ -152,6 +173,11 @@ func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
 	}
 
 	userID := request.RouteInt64Param(r, "userID")
+	if userID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid user ID"))
+		return
+	}
+
 	user, err := h.store.UserByID(userID)
 	if err != nil {
 		response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
@@ -195,6 +221,11 @@ func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
 	}
 
 	userID := request.RouteInt64Param(r, "userID")
+	if userID == 0 {
+		response.JSONBadRequest(w, r, errors.New("invalid user ID"))
+		return
+	}
+
 	user, err := h.store.UserByID(userID)
 	if err != nil {
 		response.JSONServerError(w, r, err)

+ 2 - 1
internal/http/server/httpd.go

@@ -250,7 +250,8 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
 	subrouter.PathPrefix("/reader/api/0").Handler(googleReaderHandler)
 
 	if config.Opts.HasAPI() {
-		api.Serve(subrouter, store, pool)
+		apiHandler := http.StripPrefix(config.Opts.BasePath(), api.NewHandler(store, pool))
+		subrouter.PathPrefix("/v1").Handler(apiHandler)
 	}
 	ui.Serve(subrouter, store, pool)