summaryrefslogtreecommitdiff
path: root/player-server/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'player-server/internal/api')
-rw-r--r--player-server/internal/api/handlers_more_test.go21
-rw-r--r--player-server/internal/api/handlers_test.go61
-rw-r--r--player-server/internal/api/middleware.go4
-rw-r--r--player-server/internal/api/server.go102
4 files changed, 115 insertions, 73 deletions
diff --git a/player-server/internal/api/handlers_more_test.go b/player-server/internal/api/handlers_more_test.go
index da094ac..451e911 100644
--- a/player-server/internal/api/handlers_more_test.go
+++ b/player-server/internal/api/handlers_more_test.go
@@ -338,14 +338,19 @@ func TestServer_Login_negativePaths(t *testing.T) {
cfg := &internal.Config{SessionTimeoutHours: 24}
t.Run("invalid json", func(t *testing.T) {
- authSvc := &service.MockAuthService{}
- srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(`bad`)))
- req.Header.Set("Content-Type", "application/json")
- rr := httptest.NewRecorder()
- srv.ServeHTTP(rr, req)
- if rr.Code != http.StatusBadRequest {
- t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code)
+ paths := []string{"/api/login", "/api/v1/auth/login"}
+ for _, path := range paths {
+ t.Run(path, func(t *testing.T) {
+ authSvc := &service.MockAuthService{}
+ srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
+ req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`bad`)))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code)
+ }
+ })
}
})
diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go
index 1041f2f..e900886 100644
--- a/player-server/internal/api/handlers_test.go
+++ b/player-server/internal/api/handlers_test.go
@@ -128,8 +128,10 @@ func TestMiddleware_BootstrapRedirect(t *testing.T) {
}{
{"public bootstrap html", "/bootstrap.html", 0, nil, http.StatusOK, ""},
{"public api bootstrap", "/api/bootstrap", 0, nil, http.StatusOK, ""},
+ {"public api v1 auth bootstrap", "/api/v1/auth/bootstrap", 0, nil, http.StatusOK, ""},
{"public login html", "/login.html", 0, nil, http.StatusOK, ""},
{"public api login", "/api/login", 0, nil, http.StatusOK, ""},
+ {"public api v1 auth login", "/api/v1/auth/login", 0, nil, http.StatusOK, ""},
{"public healthz", "/healthz", 0, nil, http.StatusOK, ""},
{"public readyz", "/readyz", 0, nil, http.StatusOK, ""},
{"protected no users", "/", 0, nil, http.StatusTemporaryRedirect, "/bootstrap.html"},
@@ -399,14 +401,19 @@ func TestServer_Bootstrap(t *testing.T) {
})
t.Run("missing fields", func(t *testing.T) {
- store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}}
- authSvc := service.NewAuthService(store, clk, hasher, nil)
- srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(`{"username":""}`)))
- rr := httptest.NewRecorder()
- srv.ServeHTTP(rr, req)
- if rr.Code != http.StatusBadRequest {
- t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code)
+ paths := []string{"/api/bootstrap", "/api/v1/auth/bootstrap"}
+ for _, path := range paths {
+ t.Run(path, func(t *testing.T) {
+ store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}}
+ authSvc := service.NewAuthService(store, clk, hasher, nil)
+ srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
+ req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"username":""}`)))
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code)
+ }
+ })
}
})
@@ -1375,19 +1382,31 @@ func TestServer_ListSets(t *testing.T) {
cfg := &internal.Config{SessionTimeoutHours: 24}
srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, ms, ms, ms, ms, ms, nil, nil, nil, nil)
- req := httptest.NewRequest(http.MethodGet, "/api/sets", nil)
- req.AddCookie(addSessionCookie(t, store, sm, 1))
- rr := httptest.NewRecorder()
- srv.ServeHTTP(rr, req)
- if rr.Code != http.StatusOK {
- t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code)
- }
- var sets []model.Set
- if err := json.Unmarshal(rr.Body.Bytes(), &sets); err != nil {
- t.Fatalf("unmarshal response: %v", err)
- }
- if len(sets) != 1 || sets[0].Name != "music" {
- t.Fatalf("unexpected sets response")
+ var legacyBody []byte
+ for _, path := range []string{"/api/sets", "/api/v1/sets"} {
+ t.Run(path, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.AddCookie(addSessionCookie(t, store, sm, 1))
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code)
+ }
+ var sets []model.Set
+ if err := json.Unmarshal(rr.Body.Bytes(), &sets); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if len(sets) != 1 || sets[0].Name != "music" {
+ t.Fatalf("unexpected sets response")
+ }
+ if path == "/api/sets" {
+ legacyBody = append([]byte(nil), rr.Body.Bytes()...)
+ return
+ }
+ if !bytes.Equal(legacyBody, rr.Body.Bytes()) {
+ t.Fatalf("versioned response differs from legacy response")
+ }
+ })
}
}
diff --git a/player-server/internal/api/middleware.go b/player-server/internal/api/middleware.go
index 8fcf1e3..5585be9 100644
--- a/player-server/internal/api/middleware.go
+++ b/player-server/internal/api/middleware.go
@@ -105,7 +105,9 @@ func (mw *Middleware) BootstrapRedirect(next http.Handler) http.Handler {
func isBootstrapPublic(path string) bool {
switch path {
- case "/bootstrap.html", "/api/bootstrap", "/login.html", "/api/login", "/healthz", "/readyz",
+ case "/bootstrap.html", "/api/bootstrap", "/api/v1/auth/bootstrap",
+ "/login.html", "/api/login", "/api/v1/auth/login",
+ "/healthz", "/readyz",
"/favicon.svg", "/favicon.ico", "/logo.svg", "/logo.png", "/manifest.json", "/sw.js":
return true
}
diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go
index 7a816c2..abcbafd 100644
--- a/player-server/internal/api/server.go
+++ b/player-server/internal/api/server.go
@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"strconv"
+ "strings"
"time"
"codeberg.org/snonux/player/internal"
@@ -130,10 +131,25 @@ func publicMethod(method string, handler http.HandlerFunc) http.HandlerFunc {
}
}
+func (s *Server) handleBoth(method, path string, h http.Handler) {
+ s.mux.Handle(method+" "+path, h)
+ s.mux.Handle(method+" "+apiV1Path(path), h)
+}
+
+func apiV1Path(path string) string {
+ const apiPrefix = "/api/"
+ if !strings.HasPrefix(path, apiPrefix) {
+ panic("apiV1Path: path must start with /api/")
+ }
+ return "/api/v1/" + strings.TrimPrefix(path, apiPrefix)
+}
+
// routesPublic wires the fully-public API endpoints (bootstrap, login, probes).
func (s *Server) routesPublic() {
s.mux.HandleFunc("/api/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap))
+ s.mux.HandleFunc("/api/v1/auth/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap))
s.mux.HandleFunc("/api/login", publicMethod(http.MethodPost, s.handleLogin))
+ s.mux.HandleFunc("/api/v1/auth/login", publicMethod(http.MethodPost, s.handleLogin))
s.mux.HandleFunc("/healthz", publicMethod(http.MethodGet, s.handleHealthz))
s.mux.HandleFunc("/readyz", publicMethod(http.MethodGet, s.handleReadyz))
}
@@ -170,72 +186,72 @@ func (s *Server) routesHTML() {
// routesAuth wires the logout route.
func (s *Server) routesAuth() {
- s.mux.Handle("POST /api/logout", s.requireSession(s.handleLogout))
+ s.handleBoth(http.MethodPost, "/api/logout", s.requireSession(s.handleLogout))
}
// routesConfig wires authenticated client configuration.
func (s *Server) routesConfig() {
- s.mux.Handle("GET /api/config", s.requireSession(s.handleConfig))
+ s.handleBoth(http.MethodGet, "/api/config", s.requireSession(s.handleConfig))
}
// routesSets wires the set-related API routes.
func (s *Server) routesSets() {
- s.mux.Handle("GET /api/sets", s.requireSession(s.handleListSets))
- s.mux.Handle("GET /api/sets/{id}/browse", s.requireSession(s.handleBrowseSet))
- s.mux.Handle("GET /api/sets/{id}/cover", s.requireSession(s.handleGetSetCover))
- s.mux.Handle("POST /api/sets/{id}/cover", s.requireSession(s.handlePostSetCover))
- s.mux.Handle("POST /api/sets/{id}/upload", s.requireSession(s.handleUpload))
+ s.handleBoth(http.MethodGet, "/api/sets", s.requireSession(s.handleListSets))
+ s.handleBoth(http.MethodGet, "/api/sets/{id}/browse", s.requireSession(s.handleBrowseSet))
+ s.handleBoth(http.MethodGet, "/api/sets/{id}/cover", s.requireSession(s.handleGetSetCover))
+ s.handleBoth(http.MethodPost, "/api/sets/{id}/cover", s.requireSession(s.handlePostSetCover))
+ s.handleBoth(http.MethodPost, "/api/sets/{id}/upload", s.requireSession(s.handleUpload))
}
// routesMedia wires the media-related API routes.
func (s *Server) routesMedia() {
- s.mux.Handle("GET /api/media", s.requireSession(s.handleListMedia))
- s.mux.Handle("GET /api/media/{id}", s.requireSession(s.handleGetMedia))
- s.mux.Handle("GET /api/media/{id}/stream", s.requireSession(s.handleStream))
- s.mux.Handle("GET /api/media/{id}/download", s.requireSession(s.handleDownload))
- s.mux.Handle("GET /api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail))
- s.mux.Handle("POST /api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail))
- s.mux.Handle("POST /api/media/{id}/favorite", s.requireSession(s.handleFavorite))
- s.mux.Handle("GET /api/tags", s.requireSession(s.handleListTags))
- s.mux.Handle("POST /api/media/{id}/tags", s.requireSession(s.handleAddTag))
- s.mux.Handle("DELETE /api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag))
- s.mux.Handle("DELETE /api/media/{id}", s.requireSession(s.handleSoftDelete))
- s.mux.Handle("POST /api/media/{id}/restore", s.requireSession(s.handleRestore))
- s.mux.Handle("POST /api/media/{id}/shares", s.requireSession(s.handleCreateShare))
- s.mux.Handle("GET /api/media/{id}/shares", s.requireSession(s.handleListShares))
+ s.handleBoth(http.MethodGet, "/api/media", s.requireSession(s.handleListMedia))
+ s.handleBoth(http.MethodGet, "/api/media/{id}", s.requireSession(s.handleGetMedia))
+ s.handleBoth(http.MethodGet, "/api/media/{id}/stream", s.requireSession(s.handleStream))
+ s.handleBoth(http.MethodGet, "/api/media/{id}/download", s.requireSession(s.handleDownload))
+ s.handleBoth(http.MethodGet, "/api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail))
+ s.handleBoth(http.MethodPost, "/api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail))
+ s.handleBoth(http.MethodPost, "/api/media/{id}/favorite", s.requireSession(s.handleFavorite))
+ s.handleBoth(http.MethodGet, "/api/tags", s.requireSession(s.handleListTags))
+ s.handleBoth(http.MethodPost, "/api/media/{id}/tags", s.requireSession(s.handleAddTag))
+ s.handleBoth(http.MethodDelete, "/api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag))
+ s.handleBoth(http.MethodDelete, "/api/media/{id}", s.requireSession(s.handleSoftDelete))
+ s.handleBoth(http.MethodPost, "/api/media/{id}/restore", s.requireSession(s.handleRestore))
+ s.handleBoth(http.MethodPost, "/api/media/{id}/shares", s.requireSession(s.handleCreateShare))
+ s.handleBoth(http.MethodGet, "/api/media/{id}/shares", s.requireSession(s.handleListShares))
}
// routesNotes wires the notes API routes.
func (s *Server) routesNotes() {
- s.mux.Handle("GET /api/media/{id}/notes", s.requireSession(s.handleGetNote))
- s.mux.Handle("POST /api/media/{id}/notes", s.requireSession(s.handleUpsertNote))
- s.mux.Handle("DELETE /api/media/{id}/notes", s.requireSession(s.handleDeleteNote))
+ s.handleBoth(http.MethodGet, "/api/media/{id}/notes", s.requireSession(s.handleGetNote))
+ s.handleBoth(http.MethodPost, "/api/media/{id}/notes", s.requireSession(s.handleUpsertNote))
+ s.handleBoth(http.MethodDelete, "/api/media/{id}/notes", s.requireSession(s.handleDeleteNote))
}
// routesProgress wires the progress API routes.
func (s *Server) routesProgress() {
- s.mux.Handle("POST /api/progress", s.requireSession(s.handleProgress))
- s.mux.Handle("POST /api/progress/status", s.requireSession(s.handleProgressStatus))
- s.mux.Handle("GET /api/in-progress", s.requireSession(s.handleInProgress))
+ s.handleBoth(http.MethodPost, "/api/progress", s.requireSession(s.handleProgress))
+ s.handleBoth(http.MethodPost, "/api/progress/status", s.requireSession(s.handleProgressStatus))
+ s.handleBoth(http.MethodGet, "/api/in-progress", s.requireSession(s.handleInProgress))
}
// routesShares wires the share-management API routes.
func (s *Server) routesShares() {
- s.mux.Handle("DELETE /api/shares/{token}", s.requireSession(s.handleRevokeShare))
- s.mux.Handle("GET /api/shares", s.requireSession(s.handleMyShares))
+ s.handleBoth(http.MethodDelete, "/api/shares/{token}", s.requireSession(s.handleRevokeShare))
+ s.handleBoth(http.MethodGet, "/api/shares", s.requireSession(s.handleMyShares))
}
// routesAdmin wires the admin-only API routes.
func (s *Server) routesAdmin() {
- s.mux.Handle("GET /api/admin/trash", s.requireAdmin(s.handleListTrash))
- s.mux.Handle("POST /api/admin/rescan", s.requireAdmin(s.handleRescan))
- s.mux.Handle("GET /api/admin/scan-progress", s.requireAdmin(s.handleScanProgress))
- s.mux.Handle("GET /api/admin/users", s.requireAdmin(s.handleListUsers))
- s.mux.Handle("POST /api/admin/users", s.requireAdmin(s.handleCreateUser))
- s.mux.Handle("DELETE /api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser))
- s.mux.Handle("GET /api/admin/permissions", s.requireAdmin(s.handleListPermissions))
- s.mux.Handle("POST /api/admin/permissions", s.requireAdmin(s.handleGrantPermission))
- s.mux.Handle("DELETE /api/admin/permissions", s.requireAdmin(s.handleRevokePermission))
+ s.handleBoth(http.MethodGet, "/api/admin/trash", s.requireAdmin(s.handleListTrash))
+ s.handleBoth(http.MethodPost, "/api/admin/rescan", s.requireAdmin(s.handleRescan))
+ s.handleBoth(http.MethodGet, "/api/admin/scan-progress", s.requireAdmin(s.handleScanProgress))
+ s.handleBoth(http.MethodGet, "/api/admin/users", s.requireAdmin(s.handleListUsers))
+ s.handleBoth(http.MethodPost, "/api/admin/users", s.requireAdmin(s.handleCreateUser))
+ s.handleBoth(http.MethodDelete, "/api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser))
+ s.handleBoth(http.MethodGet, "/api/admin/permissions", s.requireAdmin(s.handleListPermissions))
+ s.handleBoth(http.MethodPost, "/api/admin/permissions", s.requireAdmin(s.handleGrantPermission))
+ s.handleBoth(http.MethodDelete, "/api/admin/permissions", s.requireAdmin(s.handleRevokePermission))
}
func (s *Server) routes() {
@@ -256,11 +272,11 @@ func (s *Server) routes() {
// routesPodcast wires the podcast API routes.
func (s *Server) routesPodcast() {
- s.mux.Handle("GET /api/podcasts", s.requireSession(s.handleListPodcasts))
- s.mux.Handle("POST /api/podcasts", s.requireAdmin(s.handleSubscribePodcast))
- s.mux.Handle("GET /api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes))
- s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode))
- s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/complete", s.requireSession(s.handleToggleComplete))
+ s.handleBoth(http.MethodGet, "/api/podcasts", s.requireSession(s.handleListPodcasts))
+ s.handleBoth(http.MethodPost, "/api/podcasts", s.requireAdmin(s.handleSubscribePodcast))
+ s.handleBoth(http.MethodGet, "/api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes))
+ s.handleBoth(http.MethodPost, "/api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode))
+ s.handleBoth(http.MethodPost, "/api/podcasts/episodes/{episode_id}/complete", s.requireSession(s.handleToggleComplete))
}
func (s *Server) pingStore(ctx context.Context) error {