summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers.go21
-rw-r--r--internal/api/handlers_more_test.go293
-rw-r--r--internal/api/handlers_test.go1
-rw-r--r--internal/service/media.go18
-rw-r--r--internal/service/media_test.go9
-rw-r--r--internal/service/no_rows_test.go8
6 files changed, 269 insertions, 81 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)
diff --git a/internal/service/media.go b/internal/service/media.go
index f950db3..6f4b2db 100644
--- a/internal/service/media.go
+++ b/internal/service/media.go
@@ -35,8 +35,11 @@ func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaR
// Sentinel errors returned by the media service layer.
var (
- ErrNotFound = errors.New("not found")
- ErrForbidden = errors.New("access denied")
+ ErrNotFound = errors.New("not found")
+ ErrForbidden = errors.New("access denied")
+ ErrShareNotFound = errors.New("share not found")
+ ErrShareExpired = errors.New("share expired")
+ ErrMediaNotFound = errors.New("media not found")
)
func (s *mediaService) ListSets(ctx context.Context, userID int64) ([]model.Set, error) {
@@ -510,16 +513,16 @@ func (s *mediaService) ValidateShareToken(ctx context.Context, token string) (*m
return nil, fmt.Errorf("get share: %w", err)
}
if share == nil {
- return nil, nil
+ return nil, ErrShareNotFound
}
now := s.clock.Now()
if now.After(share.ExpiresAt) {
- return nil, nil
+ return nil, ErrShareExpired
}
if share.MaxUses != nil && share.UsedCount >= *share.MaxUses {
- return nil, nil
+ return nil, ErrShareExpired
}
return share, nil
@@ -530,16 +533,13 @@ func (s *mediaService) StreamSharedMedia(ctx context.Context, token string) (*Fi
if err != nil {
return nil, err
}
- if share == nil {
- return nil, errors.New("invalid or expired share")
- }
media, err := s.store.GetMediaByID(ctx, share.MediaID)
if err != nil {
return nil, fmt.Errorf("get media: %w", err)
}
if media == nil {
- return nil, errors.New("media not found")
+ return nil, ErrMediaNotFound
}
_ = s.store.UseShare(ctx, token)
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index 1ab7dfb..33912b4 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -905,14 +905,17 @@ func TestMediaService_ValidateShareToken(t *testing.T) {
}
svc := NewMediaService(store, newMockClock(), "/tmp/media")
res, err := svc.ValidateShareToken(ctx, "abc")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
if tt.wantValid {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
if res == nil {
t.Fatal("expected valid share")
}
} else {
+ if err == nil {
+ t.Fatal("expected error for invalid share")
+ }
if res != nil {
t.Fatal("expected nil share")
}
diff --git a/internal/service/no_rows_test.go b/internal/service/no_rows_test.go
index 3606b63..9200668 100644
--- a/internal/service/no_rows_test.go
+++ b/internal/service/no_rows_test.go
@@ -135,8 +135,8 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
}
svc := NewMediaService(store, newMockClock(), "/tmp/media")
sh, err := svc.ValidateShareToken(ctx, "nope")
- if err != nil {
- t.Fatalf("expected no error, got %v", err)
+ if !errors.Is(err, ErrShareNotFound) {
+ t.Fatalf("expected ErrShareNotFound, got %v", err)
}
if sh != nil {
t.Fatalf("expected nil share, got %+v", sh)
@@ -153,8 +153,8 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
}
svc := NewMediaService(store, newMockClock(), "/tmp/media")
_, err := svc.StreamSharedMedia(ctx, "nope")
- if err == nil {
- t.Fatal("expected error for missing share")
+ if !errors.Is(err, ErrShareNotFound) {
+ t.Fatalf("expected ErrShareNotFound, got %v", err)
}
})