diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-30 12:45:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-30 12:45:18 +0300 |
| commit | 54bc9013aa0463962a1cfd67fa278de3d401d85a (patch) | |
| tree | 526c719285a6153fdc82f8a7feb7b4ef98c1ce78 /internal/api | |
| parent | 38e318e71d50e4340ddbf1ae63b3d85e958b4644 (diff) | |
ja: implement public share landing page and improve error mapping
- Add web/share.html with a minimal HTML5 video player, theme variables,
centered layout, play overlay, and Back to Home link.
- Update handleSharePage to return HTML for browser Accept headers
(text/html or empty) and JSON for application/json. Return 410 Gone
for expired shares.
- Update handleShareStream to map service errors to distinct HTTP codes:
ErrShareExpired -> 410, ErrShareNotFound/ErrMediaNotFound -> 404.
- Improve ValidateShareToken and StreamSharedMedia to return sentinel
errors (ErrShareNotFound, ErrShareExpired, ErrMediaNotFound) instead
of generic string errors.
- Update and add tests for share page HTML/JSON negotiation and share
stream 404/410 responses.
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/handlers.go | 21 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 293 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 1 |
3 files changed, 250 insertions, 65 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go index af6a0a0..672891d 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -665,9 +665,22 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") share, err := s.mediaSvc.ValidateShareToken(r.Context(), token) if err != nil || share == nil { + if err != nil && errors.Is(err, service.ErrShareExpired) { + http.Error(w, "gone", http.StatusGone) + return + } http.Error(w, "not found", http.StatusNotFound) return } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Accept") + + accept := r.Header.Get("Accept") + if strings.Contains(accept, "text/html") || accept == "" { + s.serveFile(w, r, "share.html") + return + } writeJSON(w, http.StatusOK, share) } @@ -678,6 +691,14 @@ func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") res, err := s.mediaSvc.StreamSharedMedia(r.Context(), token) if err != nil { + if errors.Is(err, service.ErrShareExpired) { + http.Error(w, "gone", http.StatusGone) + return + } + if errors.Is(err, service.ErrShareNotFound) || errors.Is(err, service.ErrMediaNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index e7eb99a..d2ef424 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -865,80 +866,242 @@ func TestServer_RevokeShare(t *testing.T) { func TestServer_SharePage(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} + fs := newTestFS(map[string]string{ + "index.html": "index", + "login.html": "login", + "share.html": "<html>share</html>", + "bootstrap.html": "bootstrap", + }) - tests := []struct { - name string - token string - svcNil bool - svcErr error - share *model.Share - wantCode int - }{ - {"nil service", "abc", true, nil, nil, http.StatusNotImplemented}, - {"error", "abc", false, errors.New("boom"), nil, http.StatusNotFound}, - {"not found", "abc", false, nil, nil, http.StatusNotFound}, - {"ok", "abc", false, nil, &model.Share{Token: "abc", MediaID: 1}, http.StatusOK}, - } + t.Run("nil service", func(t *testing.T) { + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotImplemented { + t.Fatalf("expected %d, got %d", http.StatusNotImplemented, rr.Code) + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var ms service.MediaService - if !tt.svcNil { - ms = &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { - return tt.share, tt.svcErr - }, - } - } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/s/"+tt.token, nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != tt.wantCode { - t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) - } - }) - } + t.Run("service error", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return nil, errors.New("boom") + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("not found", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return nil, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("expired", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return nil, service.ErrShareExpired + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusGone { + t.Fatalf("expected %d, got %d", http.StatusGone, rr.Code) + } + }) + + t.Run("html default accept", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: 1}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected text/html content type, got %q", ct) + } + body := rr.Body.String() + if !strings.Contains(body, "<html>") { + t.Fatalf("expected share.html body, got %q", body) + } + }) + + t.Run("html explicit accept", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: 1}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + req.Header.Set("Accept", "text/html") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected text/html content type, got %q", ct) + } + }) + + t.Run("json accept", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: 1}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + req.Header.Set("Accept", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Fatalf("expected application/json content type, got %q", ct) + } + var body model.Share + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + if body.Token != "abc" { + t.Fatalf("unexpected token %q", body.Token) + } + }) } func TestServer_ShareStream(t *testing.T) { path := makeTempFile(t, "shared") cfg := &internal.Config{SessionTimeoutHours: 24} - tests := []struct { - name string - token string - svcNil bool - svcErr error - res *service.FileResult - wantCode int - }{ - {"nil service", "abc", true, nil, nil, http.StatusNotImplemented}, - {"service error", "abc", false, errors.New("boom"), nil, http.StatusInternalServerError}, - {"not found", "abc", false, nil, nil, http.StatusNotFound}, - {"file missing", "abc", false, nil, &service.FileResult{Path: "/nonexistent", FileName: "a.mp4"}, http.StatusNotFound}, - {"ok", "abc", false, nil, &service.FileResult{Path: path, FileName: "a.mp4"}, http.StatusOK}, - } + t.Run("nil service", func(t *testing.T) { + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotImplemented { + t.Fatalf("expected %d, got %d", http.StatusNotImplemented, rr.Code) + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var ms service.MediaService - if !tt.svcNil { - ms = &service.MockMediaService{ - StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { - return tt.res, tt.svcErr - }, - } - } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/s/"+tt.token+"/stream", nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != tt.wantCode { - t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) - } - }) - } + t.Run("service error", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, errors.New("boom") + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected %d, got %d", http.StatusInternalServerError, rr.Code) + } + }) + + t.Run("not found", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, service.ErrShareNotFound + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("expired", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, service.ErrShareExpired + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusGone { + t.Fatalf("expected %d, got %d", http.StatusGone, rr.Code) + } + }) + + t.Run("media not found", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, service.ErrMediaNotFound + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("file missing", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return &service.FileResult{Path: "/nonexistent", FileName: "a.mp4"}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("ok", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return &service.FileResult{Path: path, FileName: "a.mp4"}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) } // ------------------------------------------------------------------ diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 798ac81..89aa177 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -43,6 +43,7 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm "index.html": "index", "login.html": "login", "bootstrap.html": "bootstrap", + "share.html": "share", }) } return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, fs) |
