diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index 3306aaf..8adbac1 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -14,8 +14,8 @@ import ( "github.com/rs/zerolog/log" "flowix/gateway/internal/handler" - gwmw "flowix/gateway/internal/middleware" "flowix/gateway/internal/metrics" + gwmw "flowix/gateway/internal/middleware" "flowix/gateway/internal/proxy" ) @@ -120,9 +120,12 @@ func main() { r.With(authMw).Get("/api/v1/videos/{id}/hls-token", gwmw.HLSTokenHandler(jwtSecret, internalToken, metadataURL)) // --- Metadata service --- - // Публичные GET (лист и деталь) — без JWT - r.Get("/api/v1/videos", metadataProxy.ServeHTTP) - r.Get("/api/v1/videos/*", metadataProxy.ServeHTTP) + // Публичные GET (лист и деталь) — без обязательного JWT, но с OptionalAuth: + // валидный Bearer превращается в X-User-ID, чтобы metadata отдала приватные + // видео владельцу (issue #44) + optAuth := gwmw.OptionalAuth(jwtSecret) + r.With(optAuth).Get("/api/v1/videos", metadataProxy.ServeHTTP) + r.With(optAuth).Get("/api/v1/videos/*", metadataProxy.ServeHTTP) // Защищённые мутации metadata — требуют JWT r.With(authMw).Post("/api/v1/videos", metadataProxy.ServeHTTP) diff --git a/services/gateway/internal/middleware/auth.go b/services/gateway/internal/middleware/auth.go index 82b6521..d3ff224 100644 --- a/services/gateway/internal/middleware/auth.go +++ b/services/gateway/internal/middleware/auth.go @@ -58,10 +58,14 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { // OptionalAuth — как Auth, но не требует токен: если токен есть — валидирует // и кладёт в контекст, если нет — пропускает. Удобно для публичных GET. +// Невалидный/просроченный токен — тоже анонимный проход (issue #44): публичная +// лента не должна падать 401, когда фронт прислал истёкший access-токен. func OptionalAuth(secret string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") + // клиентский X-User-ID недоверенный — убираем, ставим только после валидации JWT + r.Header.Del("X-User-ID") if h == "" || !strings.HasPrefix(h, "Bearer ") { next.ServeHTTP(w, r) return @@ -71,11 +75,15 @@ func OptionalAuth(secret string) func(http.Handler) http.Handler { return []byte(secret), nil }, jwt.WithValidMethods([]string{"HS256"})) if err != nil || !token.Valid { - // невалидный токен даже в optional — 401, чтобы клиент знал - http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + next.ServeHTTP(w, r) return } claims, _ := token.Claims.(jwt.MapClaims) + // refresh-токен не является идентичностью для чтения приватных данных + if typ, ok := claims["type"].(string); ok && typ != "" && typ != "access" { + next.ServeHTTP(w, r) + return + } sub, _ := claims["sub"].(string) if sub != "" { ctx := context.WithValue(r.Context(), UserIDKey, sub) diff --git a/services/gateway/internal/middleware/auth_test.go b/services/gateway/internal/middleware/auth_test.go index 2bb219e..c9e909f 100644 --- a/services/gateway/internal/middleware/auth_test.go +++ b/services/gateway/internal/middleware/auth_test.go @@ -74,3 +74,84 @@ func TestAuthRejectsRefreshToken(t *testing.T) { t.Fatalf("want 401 for refresh token got %d", w.Code) } } + +// Issue #44: OptionalAuth must strip a client-supplied X-User-ID — it is the +// trust boundary for owner-scoped visibility on public video reads. +func TestOptionalAuthStripsForgedUserID(t *testing.T) { + secret := "test-secret" + var sawHeader string + h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawHeader = r.Header.Get("X-User-ID") + w.WriteHeader(200) + })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-User-ID", "victim") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 || sawHeader != "" { + t.Fatalf("forged X-User-ID must be stripped, got %q (code %d)", sawHeader, w.Code) + } +} + +func TestOptionalAuthSetsUserIDFromJWT(t *testing.T) { + secret := "test-secret" + var sawHeader string + h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawHeader = r.Header.Get("X-User-ID") + w.WriteHeader(200) + })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-User-ID", "victim") + req.Header.Set("Authorization", "Bearer "+mustToken(secret, "u1")) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 || sawHeader != "u1" { + t.Fatalf("valid JWT must set X-User-ID=u1, got %q (code %d)", sawHeader, w.Code) + } +} + +// Issue #44 (review): an expired/invalid token on a public endpoint must +// degrade to anonymous, not 401 — the frontend sends stored tokens on reads. +func TestOptionalAuthInvalidTokenIsAnonymous(t *testing.T) { + secret := "test-secret" + called := false + var sawHeader string + h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + sawHeader = r.Header.Get("X-User-ID") + w.WriteHeader(200) + })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-User-ID", "victim") + req.Header.Set("Authorization", "Bearer invalid.token.here") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 || !called || sawHeader != "" { + t.Fatalf("invalid token must be anonymous, got code %d called %v header %q", w.Code, called, sawHeader) + } +} + +// Issue #44 review: a refresh token is not an access identity — OptionalAuth +// must treat it as anonymous (same rule as AuthMiddleware's type check). +func TestOptionalAuthRejectsRefreshToken(t *testing.T) { + secret := "test-secret" + var sawHeader string + called := false + h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + sawHeader = r.Header.Get("X-User-ID") + w.WriteHeader(200) + })) + refresh := func() string { + tt := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "type": "refresh"}) + s, _ := tt.SignedString([]byte(secret)) + return s + }() + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+refresh) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 || !called || sawHeader != "" { + t.Fatalf("refresh token must be anonymous, got code %d called %v header %q", w.Code, called, sawHeader) + } +} diff --git a/services/metadata/cmd/server/main.go b/services/metadata/cmd/server/main.go index 157ef03..4318496 100644 --- a/services/metadata/cmd/server/main.go +++ b/services/metadata/cmd/server/main.go @@ -154,9 +154,12 @@ func main() { r.Patch("/api/v1/videos/{id}", vh.Update) r.Delete("/api/v1/videos/{id}", vh.Delete) }) - // public list/get - r.Get("/api/v1/videos", vh.List) - r.Get("/api/v1/videos/{id}", vh.Get) + // public list/get: optional JWT — владелец видит свои private/unlisted + // (issue #44). Идентичность только из собственного JWT (X-User-ID не + // доверенный — metadata опубликована наружу). + optAuth := mw.OptionalAuth(jwtSecret) + r.With(optAuth).Get("/api/v1/videos", vh.List) + r.With(optAuth).Get("/api/v1/videos/{id}", vh.Get) // internal — protected by X-Internal-Token (gateway/nginx/transcoder) r.Group(func(r chi.Router) { r.Use(mw.InternalAuth(internalToken)) diff --git a/services/metadata/docs/docs.go b/services/metadata/docs/docs.go index 36940c2..13fe226 100644 --- a/services/metadata/docs/docs.go +++ b/services/metadata/docs/docs.go @@ -124,6 +124,12 @@ const docTemplate = `{ "$ref": "#/definitions/flowix_metadata_internal_model.Video" } }, + "403": { + "description": "forbidden (private, not owner)", + "schema": { + "type": "string" + } + }, "404": { "description": "not found", "schema": { @@ -309,6 +315,9 @@ const docTemplate = `{ }, "title": { "type": "string" + }, + "visibility": { + "$ref": "#/definitions/flowix_metadata_internal_model.Visibility" } } }, @@ -364,6 +373,9 @@ const docTemplate = `{ }, "title": { "type": "string" + }, + "visibility": { + "$ref": "#/definitions/flowix_metadata_internal_model.Visibility" } } }, @@ -405,6 +417,12 @@ const docTemplate = `{ }, "title": { "type": "string" + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "$ref": "#/definitions/flowix_metadata_internal_model.Visibility" } } }, @@ -422,6 +440,19 @@ const docTemplate = `{ "StatusReady", "StatusFailed" ] + }, + "flowix_metadata_internal_model.Visibility": { + "type": "string", + "enum": [ + "public", + "private", + "unlisted" + ], + "x-enum-varnames": [ + "VisibilityPublic", + "VisibilityPrivate", + "VisibilityUnlisted" + ] } }, "securityDefinitions": { diff --git a/services/metadata/docs/swagger.json b/services/metadata/docs/swagger.json index 03505ad..c3209b4 100644 --- a/services/metadata/docs/swagger.json +++ b/services/metadata/docs/swagger.json @@ -117,6 +117,12 @@ "$ref": "#/definitions/flowix_metadata_internal_model.Video" } }, + "403": { + "description": "forbidden (private, not owner)", + "schema": { + "type": "string" + } + }, "404": { "description": "not found", "schema": { @@ -302,6 +308,9 @@ }, "title": { "type": "string" + }, + "visibility": { + "$ref": "#/definitions/flowix_metadata_internal_model.Visibility" } } }, @@ -357,6 +366,9 @@ }, "title": { "type": "string" + }, + "visibility": { + "$ref": "#/definitions/flowix_metadata_internal_model.Visibility" } } }, @@ -398,6 +410,12 @@ }, "title": { "type": "string" + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "$ref": "#/definitions/flowix_metadata_internal_model.Visibility" } } }, @@ -415,6 +433,19 @@ "StatusReady", "StatusFailed" ] + }, + "flowix_metadata_internal_model.Visibility": { + "type": "string", + "enum": [ + "public", + "private", + "unlisted" + ], + "x-enum-varnames": [ + "VisibilityPublic", + "VisibilityPrivate", + "VisibilityUnlisted" + ] } }, "securityDefinitions": { diff --git a/services/metadata/docs/swagger.yaml b/services/metadata/docs/swagger.yaml index f42249c..f5d7161 100644 --- a/services/metadata/docs/swagger.yaml +++ b/services/metadata/docs/swagger.yaml @@ -6,6 +6,8 @@ definitions: type: string title: type: string + visibility: + $ref: '#/definitions/flowix_metadata_internal_model.Visibility' required: - title type: object @@ -44,6 +46,8 @@ definitions: type: string title: type: string + visibility: + $ref: '#/definitions/flowix_metadata_internal_model.Visibility' type: object flowix_metadata_internal_model.Video: properties: @@ -71,6 +75,10 @@ definitions: type: string title: type: string + updated_at: + type: string + visibility: + $ref: '#/definitions/flowix_metadata_internal_model.Visibility' type: object flowix_metadata_internal_model.VideoStatus: enum: @@ -84,6 +92,16 @@ definitions: - StatusProcessing - StatusReady - StatusFailed + flowix_metadata_internal_model.Visibility: + enum: + - public + - private + - unlisted + type: string + x-enum-varnames: + - VisibilityPublic + - VisibilityPrivate + - VisibilityUnlisted info: contact: {} description: Video metadata CRUD + internal status (Go chi). JWT Bearer required @@ -183,6 +201,10 @@ paths: description: OK schema: $ref: '#/definitions/flowix_metadata_internal_model.Video' + "403": + description: forbidden (private, not owner) + schema: + type: string "404": description: not found schema: diff --git a/services/metadata/internal/handler/video.go b/services/metadata/internal/handler/video.go index 2f06565..e55792a 100644 --- a/services/metadata/internal/handler/video.go +++ b/services/metadata/internal/handler/video.go @@ -20,7 +20,7 @@ import ( type VideoStore interface { Create(ctx context.Context, ownerID, title, description string) (*model.Video, error) GetByID(ctx context.Context, id string) (*model.Video, error) - List(ctx context.Context, limit, offset int) ([]model.Video, error) + List(ctx context.Context, limit, offset int, viewerID string) ([]model.Video, error) Update(ctx context.Context, id, ownerID string, req model.UpdateVideoRequest) (*model.Video, error) Delete(ctx context.Context, id, ownerID string) error UpdateStatus(ctx context.Context, id string, status model.VideoStatus, renditions []model.Rendition) error @@ -143,17 +143,11 @@ func (h *VideoHandler) Create(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param id path string true "Video ID" // @Success 200 {object} model.Video +// @Failure 403 {string} string "forbidden (private, not owner)" // @Failure 404 {string} string "not found" // @Router /api/v1/videos/{id} [get] func (h *VideoHandler) Get(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - v, err := h.repo.GetByID(r.Context(), id) - if err != nil { - writeError(w, r, http.StatusNotFound, "not found") - return - } - h.presignThumbnail(r.Context(), v) - writeJSON(w, r, http.StatusOK, v) + h.getVideo(w, r, true) } // GetInternal godoc @@ -164,7 +158,24 @@ func (h *VideoHandler) Get(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} model.Video // @Router /internal/videos/{id} [get] func (h *VideoHandler) GetInternal(w http.ResponseWriter, r *http.Request) { - h.Get(w, r) + h.getVideo(w, r, false) +} + +// getVideo fetches one video; when enforceVisibility, a private video is +// returned only to its owner (issue #44) — unlisted stays reachable by link. +func (h *VideoHandler) getVideo(w http.ResponseWriter, r *http.Request, enforceVisibility bool) { + id := chi.URLParam(r, "id") + v, err := h.repo.GetByID(r.Context(), id) + if err != nil { + writeError(w, r, http.StatusNotFound, "not found") + return + } + if enforceVisibility && v.Visibility == model.VisibilityPrivate && middleware.UserIDFromCtx(r.Context()) != v.OwnerID { + writeError(w, r, http.StatusForbidden, "forbidden") + return + } + h.presignThumbnail(r.Context(), v) + writeJSON(w, r, http.StatusOK, v) } type vodMapping struct { @@ -239,7 +250,7 @@ func (h *VideoHandler) List(w http.ResponseWriter, r *http.Request) { if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v >= 0 { offset = v } - list, err := h.repo.List(r.Context(), limit, offset) + list, err := h.repo.List(r.Context(), limit, offset, middleware.UserIDFromCtx(r.Context())) if err != nil { slog.Error("list videos failed", "error", err) writeError(w, r, http.StatusInternalServerError, "internal error") diff --git a/services/metadata/internal/handler/video_test.go b/services/metadata/internal/handler/video_test.go index 50a1270..8519e35 100644 --- a/services/metadata/internal/handler/video_test.go +++ b/services/metadata/internal/handler/video_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "net/http" "net/http/httptest" "testing" "time" @@ -42,9 +43,17 @@ func (f *fakeStore) GetByID(_ context.Context, id string) (*model.Video, error) } return v, nil } -func (f *fakeStore) List(_ context.Context, limit, offset int) ([]model.Video, error) { +func (f *fakeStore) List(_ context.Context, limit, offset int, viewerID string) ([]model.Video, error) { out := []model.Video{} for _, v := range f.videos { + // mirror the SQL visibility filter (issue #44); empty = default public + vis := v.Visibility + if vis == "" { + vis = model.VisibilityPublic + } + if vis != model.VisibilityPublic && (viewerID == "" || v.OwnerID != viewerID) { + continue + } out = append(out, *v) } if offset < len(out) { @@ -134,8 +143,10 @@ func testRouter(store VideoStore) chi.Router { r.Patch("/api/v1/videos/{id}", vh.Update) r.Delete("/api/v1/videos/{id}", vh.Delete) }) - r.Get("/api/v1/videos", vh.List) - r.Get("/api/v1/videos/{id}", vh.Get) + // public GET — optional JWT, как в cmd/server/main.go (issue #44) + optAuth := middleware.OptionalAuth(testSecret) + r.With(optAuth).Get("/api/v1/videos", vh.List) + r.With(optAuth).Get("/api/v1/videos/{id}", vh.Get) r.Patch("/internal/videos/{id}/status", vh.UpdateStatus) r.Get("/internal/videos/{id}", vh.GetInternal) r.Get("/internal/videos/{id}/vod", vh.GetVODMapping) @@ -458,3 +469,104 @@ func TestListThumbnailPresigned(t *testing.T) { t.Fatalf("unexpected list thumbnails: %+v", resp.Data) } } + +// Issue #44: List shows public videos to everyone; the owner additionally sees +// their own private/unlisted. Anonymous viewers see only public. +func TestListVisibilityFilter(t *testing.T) { + store := newFake() + store.videos["v-pub"] = &model.Video{ID: "v-pub", OwnerID: "o1", Title: "pub", Status: model.StatusReady, Visibility: model.VisibilityPublic} + store.videos["v-priv"] = &model.Video{ID: "v-priv", OwnerID: "o1", Title: "priv", Status: model.StatusReady, Visibility: model.VisibilityPrivate} + store.videos["v-unl"] = &model.Video{ID: "v-unl", OwnerID: "o1", Title: "unl", Status: model.StatusReady, Visibility: model.VisibilityUnlisted} + r := testRouter(store) + + listTitles := func(req *http.Request) map[string]bool { + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("list want 200 got %d %s", w.Code, w.Body.String()) + } + var resp struct { + Data []model.Video `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, v := range resp.Data { + got[v.ID] = true + } + return got + } + + got := listTitles(httptest.NewRequest("GET", "/api/v1/videos", nil)) + if len(got) != 1 || !got["v-pub"] { + t.Fatalf("anonymous should see only public, got %v", got) + } + + req := httptest.NewRequest("GET", "/api/v1/videos", nil) + req.Header.Set("Authorization", "Bearer "+signToken("o2")) + got = listTitles(req) + if len(got) != 1 || !got["v-pub"] { + t.Fatalf("other user should see only public, got %v", got) + } + + req = httptest.NewRequest("GET", "/api/v1/videos", nil) + req.Header.Set("Authorization", "Bearer "+signToken("o1")) + got = listTitles(req) + if len(got) != 3 { + t.Fatalf("owner should see all own videos, got %v", got) + } +} + +// Issue #44: private video by direct ID — 403 for non-owner (and anonymous), +// 200 for the owner; unlisted stays reachable by link; internal Get bypasses. +func TestGetVisibility(t *testing.T) { + store := newFake() + store.videos["v-priv"] = &model.Video{ID: "v-priv", OwnerID: "o1", Title: "priv", Status: model.StatusReady, Visibility: model.VisibilityPrivate} + store.videos["v-unl"] = &model.Video{ID: "v-unl", OwnerID: "o1", Title: "unl", Status: model.StatusReady, Visibility: model.VisibilityUnlisted} + r := testRouter(store) + + cases := []struct { + url string + userID string + wantGet int + }{ + {"/api/v1/videos/v-priv", "", 403}, + {"/api/v1/videos/v-priv", "o2", 403}, + {"/api/v1/videos/v-priv", "o1", 200}, + {"/api/v1/videos/v-unl", "", 200}, + {"/api/v1/videos/v-unl", "o2", 200}, + {"/internal/videos/v-priv", "", 200}, + } + for _, c := range cases { + req := httptest.NewRequest("GET", c.url, nil) + if c.userID != "" { + req.Header.Set("Authorization", "Bearer "+signToken(c.userID)) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != c.wantGet { + t.Fatalf("%s (user=%q) want %d got %d %s", c.url, c.userID, c.wantGet, w.Code, w.Body.String()) + } + } + + // X-User-ID header is not trusted — owner view requires a real JWT (issue #44 review) + req := httptest.NewRequest("GET", "/api/v1/videos/v-priv", nil) + req.Header.Set("X-User-ID", "o1") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 403 { + t.Fatalf("forged X-User-ID must not grant owner view, want 403 got %d", w.Code) + } + + // refresh token is not an access identity — must not grant owner view (issue #44 review) + refresh := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "o1", "type": "refresh"}) + rs, _ := refresh.SignedString([]byte(testSecret)) + req = httptest.NewRequest("GET", "/api/v1/videos/v-priv", nil) + req.Header.Set("Authorization", "Bearer "+rs) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 403 { + t.Fatalf("refresh token must not grant owner view, want 403 got %d", w.Code) + } +} diff --git a/services/metadata/internal/middleware/auth.go b/services/metadata/internal/middleware/auth.go index e981b8e..a7d9fe9 100644 --- a/services/metadata/internal/middleware/auth.go +++ b/services/metadata/internal/middleware/auth.go @@ -49,3 +49,43 @@ func UserIDFromCtx(ctx context.Context) string { v, _ := ctx.Value(UserIDKey).(string) return v } + +// OptionalAuth — как Auth, но не требует токен: валидный Bearer кладёт sub в +// контекст, отсутствие/невалидность токена — анонимный доступ (публичные GET +// не должны падать 401 при истёкшем access-токене, issue #44). Владелец видит +// свои private/unlisted видео только через собственный JWT — заголовок +// X-User-ID не доверенный, т.к. metadata опубликована наружу. +func OptionalAuth(secret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + next.ServeHTTP(w, r) + return + } + token, err := jwt.Parse(strings.TrimPrefix(h, "Bearer "), func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil || !token.Valid { + next.ServeHTTP(w, r) + return + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + next.ServeHTTP(w, r) + return + } + // refresh-токен не является идентичностью для чтения приватных данных + if typ, ok := claims["type"].(string); ok && typ != "" && typ != "access" { + next.ServeHTTP(w, r) + return + } + sub, _ := claims["sub"].(string) + if sub == "" { + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserIDKey, sub))) + }) + } +} diff --git a/services/metadata/internal/repository/video.go b/services/metadata/internal/repository/video.go index 02887f0..d2999e8 100644 --- a/services/metadata/internal/repository/video.go +++ b/services/metadata/internal/repository/video.go @@ -61,9 +61,11 @@ func (r *VideoRepo) GetByID(ctx context.Context, id string) (*model.Video, error return v, nil } -func (r *VideoRepo) List(ctx context.Context, limit, offset int) ([]model.Video, error) { - q := `SELECT v.id, v.owner_id, u.email, v.title, v.description, v.duration, v.status, COALESCE(v.visibility::text,'public'), v.thumbnail_s3_key, v.created_at, v.updated_at FROM videos v LEFT JOIN users u ON u.id=v.owner_id ORDER BY v.created_at DESC LIMIT $1 OFFSET $2` - rows, err := r.pool.Query(ctx, q, limit, offset) +// List returns videos visible to viewerID: everyone sees public, the owner +// also sees their own private/unlisted (issue #44). Empty viewerID = anonymous. +func (r *VideoRepo) List(ctx context.Context, limit, offset int, viewerID string) ([]model.Video, error) { + q := `SELECT v.id, v.owner_id, u.email, v.title, v.description, v.duration, v.status, COALESCE(v.visibility::text,'public'), v.thumbnail_s3_key, v.created_at, v.updated_at FROM videos v LEFT JOIN users u ON u.id=v.owner_id WHERE v.visibility='public' OR ($3 <> '' AND v.owner_id=$3) ORDER BY v.created_at DESC LIMIT $1 OFFSET $2` + rows, err := r.pool.Query(ctx, q, limit, offset, viewerID) if err != nil { return nil, err }