summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-30 12:45:18 +0300
committerPaul Buetow <paul@buetow.org>2026-04-30 12:45:18 +0300
commit54bc9013aa0463962a1cfd67fa278de3d401d85a (patch)
tree526c719285a6153fdc82f8a7feb7b4ef98c1ce78 /internal/api
parent38e318e71d50e4340ddbf1ae63b3d85e958b4644 (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.go21
-rw-r--r--internal/api/handlers_more_test.go293
-rw-r--r--internal/api/handlers_test.go1
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)