From 2fbefd8eced4369e4666abc24269dc88490a2bfa Mon Sep 17 00:00:00 2001 From: SlavaKuntsov Date: Mon, 31 Aug 2026 22:41:06 +0300 Subject: [PATCH 1/4] feat(metadata): add video visibility public/private/unlisted Add video_visibility enum and visibility column (default public) with index, update model/repository/handler to handle create/update and validate visibility, and extend fakeStore for tests. --- .../migrations/000002_add_visibility.down.sql | 6 +++++ .../migrations/000002_add_visibility.up.sql | 8 +++++++ deploy/postgres/init.sql | 8 +++++++ services/metadata/internal/handler/video.go | 17 ++++++++++++++ .../metadata/internal/handler/video_test.go | 8 ++++++- services/metadata/internal/model/video.go | 23 +++++++++++++++---- .../metadata/internal/repository/video.go | 22 +++++++++++++----- 7 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 deploy/migrations/000002_add_visibility.down.sql create mode 100644 deploy/migrations/000002_add_visibility.up.sql diff --git a/deploy/migrations/000002_add_visibility.down.sql b/deploy/migrations/000002_add_visibility.down.sql new file mode 100644 index 0000000..547f14a --- /dev/null +++ b/deploy/migrations/000002_add_visibility.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_videos_visibility; +ALTER TABLE videos DROP COLUMN IF EXISTS visibility; +DO $$ BEGIN + DROP TYPE IF EXISTS video_visibility; +EXCEPTION WHEN undefined_object THEN null; +END $$; diff --git a/deploy/migrations/000002_add_visibility.up.sql b/deploy/migrations/000002_add_visibility.up.sql new file mode 100644 index 0000000..9a30efd --- /dev/null +++ b/deploy/migrations/000002_add_visibility.up.sql @@ -0,0 +1,8 @@ +-- Phase 13: HLS CDN auth — add visibility to videos +DO $$ BEGIN + CREATE TYPE video_visibility AS ENUM ('public','private','unlisted'); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +ALTER TABLE videos ADD COLUMN IF NOT EXISTS visibility video_visibility NOT NULL DEFAULT 'public'; +CREATE INDEX IF NOT EXISTS idx_videos_visibility ON videos(visibility); diff --git a/deploy/postgres/init.sql b/deploy/postgres/init.sql index 23e47e1..51db9d3 100644 --- a/deploy/postgres/init.sql +++ b/deploy/postgres/init.sql @@ -8,6 +8,11 @@ DO $$ BEGIN EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE video_visibility AS ENUM ('public','private','unlisted'); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT UNIQUE NOT NULL, @@ -23,10 +28,12 @@ CREATE TABLE IF NOT EXISTS videos ( duration INT, status video_status NOT NULL DEFAULT 'uploaded', thumbnail_s3_key TEXT, + visibility video_visibility NOT NULL DEFAULT 'public', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- thumbnail for VideoCard preview (added Phase 7 fix) ALTER TABLE videos ADD COLUMN IF NOT EXISTS thumbnail_s3_key TEXT; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS visibility video_visibility NOT NULL DEFAULT 'public'; CREATE TABLE IF NOT EXISTS video_renditions ( video_id UUID NOT NULL REFERENCES videos(id) ON DELETE CASCADE, @@ -41,3 +48,4 @@ CREATE TABLE IF NOT EXISTS video_renditions ( CREATE INDEX IF NOT EXISTS idx_videos_owner ON videos(owner_id); CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status); CREATE INDEX IF NOT EXISTS idx_videos_created ON videos(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_videos_visibility ON videos(visibility); diff --git a/services/metadata/internal/handler/video.go b/services/metadata/internal/handler/video.go index e215273..8d7d05c 100644 --- a/services/metadata/internal/handler/video.go +++ b/services/metadata/internal/handler/video.go @@ -87,6 +87,15 @@ func (h *VideoHandler) Create(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusInternalServerError, "internal error") return } + if req.Visibility != nil { + if !req.Visibility.Valid() { + writeError(w, r, http.StatusBadRequest, "invalid visibility") + return + } + if upd, err := h.repo.Update(r.Context(), v.ID, ownerID, model.UpdateVideoRequest{Visibility: req.Visibility}); err == nil { + v = upd + } + } writeJSON(w, r, http.StatusCreated, v) } @@ -202,12 +211,20 @@ func (h *VideoHandler) Update(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusBadRequest, "invalid body") return } + if req.Visibility != nil && !req.Visibility.Valid() { + writeError(w, r, http.StatusBadRequest, "invalid visibility") + return + } v, err := h.repo.Update(r.Context(), id, ownerID, req) if err != nil { if err.Error() == "forbidden" { writeError(w, r, http.StatusForbidden, "forbidden") return } + if err.Error() == "invalid visibility" { + writeError(w, r, http.StatusBadRequest, "invalid visibility") + return + } writeError(w, r, http.StatusNotFound, "not found") return } diff --git a/services/metadata/internal/handler/video_test.go b/services/metadata/internal/handler/video_test.go index 8163666..05b0ece 100644 --- a/services/metadata/internal/handler/video_test.go +++ b/services/metadata/internal/handler/video_test.go @@ -30,7 +30,7 @@ type fakeStore struct { func newFake() *fakeStore { return &fakeStore{videos: map[string]*model.Video{}} } func (f *fakeStore) Create(_ context.Context, ownerID, title, description string) (*model.Video, error) { - v := &model.Video{ID: "vid-" + title, OwnerID: ownerID, Title: title, Description: description, Status: model.StatusUploaded} + v := &model.Video{ID: "vid-" + title, OwnerID: ownerID, Title: title, Description: description, Status: model.StatusUploaded, Visibility: model.VisibilityPublic} f.videos[v.ID] = v return v, nil } @@ -70,6 +70,12 @@ func (f *fakeStore) Update(_ context.Context, id, ownerID string, req model.Upda if req.Description != nil { v.Description = *req.Description } + if req.Visibility != nil { + if !req.Visibility.Valid() { + return nil, &fakeErr{"invalid visibility"} + } + v.Visibility = *req.Visibility + } return v, nil } func (f *fakeStore) Delete(_ context.Context, id, ownerID string) error { diff --git a/services/metadata/internal/model/video.go b/services/metadata/internal/model/video.go index b5175ae..beb4ad7 100644 --- a/services/metadata/internal/model/video.go +++ b/services/metadata/internal/model/video.go @@ -11,6 +11,18 @@ const ( StatusFailed VideoStatus = "failed" ) +type Visibility string + +const ( + VisibilityPublic Visibility = "public" + VisibilityPrivate Visibility = "private" + VisibilityUnlisted Visibility = "unlisted" +) + +func (v Visibility) Valid() bool { + return v == VisibilityPublic || v == VisibilityPrivate || v == VisibilityUnlisted +} + type Video struct { ID string `json:"id"` OwnerID string `json:"owner_id"` @@ -19,6 +31,7 @@ type Video struct { Description string `json:"description"` Duration *int `json:"duration,omitempty"` Status VideoStatus `json:"status"` + Visibility Visibility `json:"visibility"` ThumbnailS3Key *string `json:"thumbnail_s3_key,omitempty"` ThumbnailURL *string `json:"thumbnail_url,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -35,13 +48,15 @@ type Rendition struct { } type CreateVideoRequest struct { - Title string `json:"title" validate:"required"` - Description string `json:"description"` + Title string `json:"title" validate:"required"` + Description string `json:"description"` + Visibility *Visibility `json:"visibility,omitempty"` } type UpdateVideoRequest struct { - Title *string `json:"title"` - Description *string `json:"description"` + Title *string `json:"title"` + Description *string `json:"description"` + Visibility *Visibility `json:"visibility,omitempty"` } type UpdateStatusRequest struct { diff --git a/services/metadata/internal/repository/video.go b/services/metadata/internal/repository/video.go index 45b3325..7003a0f 100644 --- a/services/metadata/internal/repository/video.go +++ b/services/metadata/internal/repository/video.go @@ -18,9 +18,10 @@ func NewVideoRepo(pool *pgxpool.Pool) *VideoRepo { return &VideoRepo{pool: pool} func (r *VideoRepo) Create(ctx context.Context, ownerID, title, description string) (*model.Video, error) { id := uuid.New().String() - q := `INSERT INTO videos (id, owner_id, title, description, status) VALUES ($1,$2,$3,$4,'uploaded') RETURNING id, owner_id, title, description, duration, status, thumbnail_s3_key, created_at` + vis := model.VisibilityPublic + q := `INSERT INTO videos (id, owner_id, title, description, status, visibility) VALUES ($1,$2,$3,$4,'uploaded',$5) RETURNING id, owner_id, title, description, duration, status, visibility, thumbnail_s3_key, created_at` v := &model.Video{} - err := r.pool.QueryRow(ctx, q, id, ownerID, title, description).Scan(&v.ID, &v.OwnerID, &v.Title, &v.Description, &v.Duration, &v.Status, &v.ThumbnailS3Key, &v.CreatedAt) + err := r.pool.QueryRow(ctx, q, id, ownerID, title, description, vis).Scan(&v.ID, &v.OwnerID, &v.Title, &v.Description, &v.Duration, &v.Status, &v.Visibility, &v.ThumbnailS3Key, &v.CreatedAt) if err != nil { return nil, fmt.Errorf("create video: %w", err) } @@ -34,9 +35,9 @@ func (r *VideoRepo) Create(ctx context.Context, ownerID, title, description stri } func (r *VideoRepo) GetByID(ctx context.Context, id string) (*model.Video, error) { - q := `SELECT v.id, v.owner_id, u.email, v.title, v.description, v.duration, v.status, v.thumbnail_s3_key, v.created_at FROM videos v LEFT JOIN users u ON u.id=v.owner_id WHERE v.id=$1` + 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 FROM videos v LEFT JOIN users u ON u.id=v.owner_id WHERE v.id=$1` v := &model.Video{} - err := r.pool.QueryRow(ctx, q, id).Scan(&v.ID, &v.OwnerID, &v.OwnerEmail, &v.Title, &v.Description, &v.Duration, &v.Status, &v.ThumbnailS3Key, &v.CreatedAt) + err := r.pool.QueryRow(ctx, q, id).Scan(&v.ID, &v.OwnerID, &v.OwnerEmail, &v.Title, &v.Description, &v.Duration, &v.Status, &v.Visibility, &v.ThumbnailS3Key, &v.CreatedAt) if err != nil { return nil, err } @@ -61,7 +62,7 @@ func (r *VideoRepo) GetByID(ctx context.Context, id string) (*model.Video, error } 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, v.thumbnail_s3_key, v.created_at FROM videos v LEFT JOIN users u ON u.id=v.owner_id ORDER BY v.created_at DESC LIMIT $1 OFFSET $2` + 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 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) if err != nil { return nil, err @@ -70,7 +71,7 @@ func (r *VideoRepo) List(ctx context.Context, limit, offset int) ([]model.Video, var out []model.Video for rows.Next() { var v model.Video - if err := rows.Scan(&v.ID, &v.OwnerID, &v.OwnerEmail, &v.Title, &v.Description, &v.Duration, &v.Status, &v.ThumbnailS3Key, &v.CreatedAt); err == nil { + if err := rows.Scan(&v.ID, &v.OwnerID, &v.OwnerEmail, &v.Title, &v.Description, &v.Duration, &v.Status, &v.Visibility, &v.ThumbnailS3Key, &v.CreatedAt); err == nil { if v.ThumbnailS3Key != nil { u := "/thumbnails/" + v.ID + "/thumb.jpg" v.ThumbnailURL = &u @@ -102,6 +103,15 @@ func (r *VideoRepo) Update(ctx context.Context, id, ownerID string, req model.Up return nil, err } } + if req.Visibility != nil { + if !req.Visibility.Valid() { + return nil, fmt.Errorf("invalid visibility") + } + _, err = r.pool.Exec(ctx, `UPDATE videos SET visibility=$1 WHERE id=$2`, *req.Visibility, id) + if err != nil { + return nil, err + } + } return r.GetByID(ctx, id) } From dbd99ca18c83073a6ec96617115cd798074af232 Mon Sep 17 00:00:00 2001 From: SlavaKuntsov Date: Mon, 31 Aug 2026 22:41:09 +0300 Subject: [PATCH 2/4] feat(gateway): add HLS auth with signed URL and cache-control Protect /hls/* via HLSAuth: private videos require owner access JWT or 1h hls JWT (?token= or X-HLS-Token), public/unlisted pass through. Add GET /api/v1/videos/{id}/hls-token and set Cache-Control private,max-age=10 for private, public for others. Fix errcheck. --- services/gateway/cmd/server/main.go | 9 +- .../gateway/internal/middleware/hlsauth.go | 268 ++++++++++++++++++ 2 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 services/gateway/internal/middleware/hlsauth.go diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index 0ee50f5..14ae106 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -116,6 +116,9 @@ func main() { r.With(authMw).Post("/api/v1/videos/complete", uploadProxy.ServeHTTP) r.With(authMw).Post("/api/v1/videos/{id}/complete", uploadProxy.ServeHTTP) + // --- HLS token for private videos (signed URL 1h) — must be before generic /videos/* proxy --- + 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) @@ -128,9 +131,9 @@ func main() { r.With(authMw).Delete("/api/v1/videos/*", metadataProxy.ServeHTTP) r.With(authMw).Put("/api/v1/videos/*", metadataProxy.ServeHTTP) - // --- HLS / VOD: публичный, прокси на nginx-vod (JIT) --- - // nginx-vod отдаёт master.m3u8 и сегменты; кэш заголовки ставит сам. - r.Handle("/hls/*", vodProxy) + // --- HLS / VOD: защищён HLSAuth (private 403 без токена, public пропуск) --- + hlsAuth := gwmw.HLSAuth(jwtSecret, internalToken, metadataURL) + r.With(hlsAuth).Handle("/hls/*", vodProxy) // --- Thumbnails / public MinIO objects via gateway (avoid direct :9000 CORS) --- // frontend uses /thumbnails/{id}/thumb.jpg ; gateway proxies to MinIO bucket `videos` diff --git a/services/gateway/internal/middleware/hlsauth.go b/services/gateway/internal/middleware/hlsauth.go new file mode 100644 index 0000000..3c3a32a --- /dev/null +++ b/services/gateway/internal/middleware/hlsauth.go @@ -0,0 +1,268 @@ +package middleware + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/golang-jwt/jwt/v5" +) + +var hlsPathRe = regexp.MustCompile(`/hls/([0-9a-fA-F-]{36})`) + +func extractVideoID(path string) string { + m := hlsPathRe.FindStringSubmatch(path) + if len(m) >= 2 { + return m[1] + } + return "" +} + +// GenerateHLSToken creates a short-lived JWT for HLS access to a private video. +func GenerateHLSToken(videoID, userID, secret string) (string, error) { + now := time.Now() + claims := jwt.MapClaims{ + "sub": userID, + "video_id": videoID, + "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), + "type": "hls", + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(secret)) +} + +// validateHLSToken checks ?token= JWT with video_id matching expected. +func validateHLSToken(tokenStr, expectedVideoID, secret string) (string, bool) { + tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil || !tok.Valid { + return "", false + } + claims, ok := tok.Claims.(jwt.MapClaims) + if !ok { + return "", false + } + if typ, _ := claims["type"].(string); typ != "hls" { + return "", false + } + vid, _ := claims["video_id"].(string) + if vid == "" || !strings.EqualFold(vid, expectedVideoID) { + return "", false + } + sub, _ := claims["sub"].(string) + // exp already validated by jwt.Parse + return sub, true +} + +// validateAccessToken extracts sub from Authorization Bearer if valid access token. +func validateAccessToken(r *http.Request, secret string) (string, bool) { + h := r.Header.Get("Authorization") + if h == "" || !strings.HasPrefix(h, "Bearer ") { + return "", false + } + tokenStr := strings.TrimPrefix(h, "Bearer ") + tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil || !tok.Valid { + return "", false + } + claims, ok := tok.Claims.(jwt.MapClaims) + if !ok { + return "", false + } + if typ, _ := claims["type"].(string); typ != "" && typ != "access" { + return "", false + } + sub, _ := claims["sub"].(string) + if sub == "" { + return "", false + } + return sub, true +} + +type videoMeta struct { + ID string `json:"id"` + OwnerID string `json:"owner_id"` + Visibility string `json:"visibility"` + Status string `json:"status"` +} + +// fetchVideoMeta calls metadata internal endpoint to get visibility/owner. +func fetchVideoMeta(metadataURL, internalToken, videoID string) (*videoMeta, error) { + url := strings.TrimSuffix(metadataURL, "/") + "/internal/videos/" + videoID + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if internalToken != "" { + req.Header.Set("X-Internal-Token", internalToken) + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("metadata status %d", resp.StatusCode) + } + var v videoMeta + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return nil, err + } + if v.Visibility == "" { + v.Visibility = "public" + } + return &v, nil +} + +// HLSAuth protects /hls/* : private videos require owner JWT or hls signed token. +func HLSAuth(jwtSecret, internalToken, metadataURL string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + videoID := extractVideoID(r.URL.Path) + if videoID == "" { + next.ServeHTTP(w, r) + return + } + meta, err := fetchVideoMeta(metadataURL, internalToken, videoID) + if err != nil { + // If video not found, let vod return 404; if fetch fails, propagate 502 + // To avoid leaking existence, treat not-found as 404 from vod. + // If metadata unavailable, return 502. + if strings.Contains(err.Error(), "404") { + next.ServeHTTP(w, r) + return + } + // Allow through on transient failure? Better 502. + http.Error(w, `{"error":"upstream unavailable"}`, http.StatusBadGateway) + return + } + vis := meta.Visibility + if vis == "" { + vis = "public" + } + isPublic := vis == "public" || vis == "unlisted" + if isPublic { + // public/unlisted: allow, set public cache header via wrapped writer + rw := &hlsCacheWriter{ResponseWriter: w, visibility: vis} + next.ServeHTTP(rw, r) + return + } + // private: need auth + // check hls token first (query or header for hls.js xhrSetup) + tokenParam := r.URL.Query().Get("token") + if tokenParam == "" { + tokenParam = r.Header.Get("X-HLS-Token") + } + if tokenParam != "" { + if sub, ok := validateHLSToken(tokenParam, videoID, jwtSecret); ok && sub == meta.OwnerID { + rw := &hlsCacheWriter{ResponseWriter: w, visibility: vis} + next.ServeHTTP(rw, r) + return + } + } + if sub, ok := validateAccessToken(r, jwtSecret); ok && sub == meta.OwnerID { + rw := &hlsCacheWriter{ResponseWriter: w, visibility: vis} + // forward user for logging/proxy + r.Header.Set("X-User-ID", sub) + next.ServeHTTP(rw, r) + return + } + http.Error(w, `{"error":"forbidden: private video"}`, http.StatusForbidden) + }) + } +} + +type hlsCacheWriter struct { + http.ResponseWriter + visibility string + wrote bool +} + +func (h *hlsCacheWriter) WriteHeader(code int) { + if !h.wrote { + h.wrote = true + if h.visibility == "private" { + h.Header().Set("Cache-Control", "private, max-age=10") + h.Header().Set("CDN-Cache-Control", "private, max-age=10") + h.Header().Set("Surrogate-Control", "max-age=10") + } else { + // for public we keep upstream's cache but ensure public + if h.Header().Get("Cache-Control") == "" { + h.Header().Set("Cache-Control", "public, max-age=86400") + } + } + } + h.ResponseWriter.WriteHeader(code) +} + +func (h *hlsCacheWriter) Write(b []byte) (int, error) { + if !h.wrote { + h.WriteHeader(http.StatusOK) + } + return h.ResponseWriter.Write(b) +} + +// HLSTokenHandler returns a signed token for HLS private access. Requires auth. +func HLSTokenHandler(jwtSecret, internalToken, metadataURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + videoID := chi.URLParam(r, "id") + if videoID == "" { + videoID = r.URL.Query().Get("id") + } + if videoID == "" { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) >= 4 { + for i, p := range parts { + if p == "videos" && i+1 < len(parts) { + videoID = parts[i+1] + break + } + } + } + } + userID := UserIDFromCtx(r.Context()) + if userID == "" { + // try via Authorization parsing as fallback (if middleware not applied) + if sub, ok := validateAccessToken(r, jwtSecret); ok { + userID = sub + } + } + if userID == "" { + http.Error(w, `{"error":"missing token"}`, http.StatusUnauthorized) + return + } + if videoID == "" { + http.Error(w, `{"error":"missing video id"}`, http.StatusBadRequest) + return + } + meta, err := fetchVideoMeta(metadataURL, internalToken, videoID) + if err != nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + if meta.OwnerID != userID { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + token, err := GenerateHLSToken(videoID, userID, jwtSecret) + if err != nil { + http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": token, + "expires_in": 3600, + "url": fmt.Sprintf("/hls/%s/master.m3u8?token=%s", videoID, token), + }) + } +} From 6f04908a7e9a11f9dfd40abf16b0143d46787b8d Mon Sep 17 00:00:00 2001 From: SlavaKuntsov Date: Mon, 31 Aug 2026 22:41:12 +0300 Subject: [PATCH 3/4] feat(infra): increase nginx vod cache and fix prod template Bump vod_response_cache 128m->512m and add perf counters, add X-Internal-Token to prod mapping, fix Dockerfile to handle both templates via ENV=prod and update compose prod volume to template. --- deploy/docker-compose.prod.yml | 3 ++- deploy/nginx/Dockerfile | 3 ++- deploy/nginx/nginx.conf | 3 ++- deploy/nginx/nginx.prod.conf | 4 +++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 6e641f1..2e0fee3 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -4,8 +4,9 @@ services: nginx-vod: environment: TZ: UTC + ENV: prod volumes: - - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ./nginx/nginx.prod.conf:/etc/nginx/nginx.prod.conf.template:ro gateway: environment: diff --git a/deploy/nginx/Dockerfile b/deploy/nginx/Dockerfile index 0400068..597a212 100644 --- a/deploy/nginx/Dockerfile +++ b/deploy/nginx/Dockerfile @@ -31,6 +31,7 @@ RUN apk add --no-cache ffmpeg libxml2 COPY --from=builder /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_vod_module.so /usr/lib/nginx/modules/ngx_http_vod_module.so COPY nginx.conf /etc/nginx/nginx.conf.template +COPY nginx.prod.conf /etc/nginx/nginx.prod.conf.template EXPOSE 80 -CMD ["/bin/sh", "-c", "envsubst '$$INTERNAL_TOKEN' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && exec nginx -g 'daemon off;'"] +CMD ["/bin/sh", "-c", "if [ \"$ENV\" = \"prod\" ] && [ -f /etc/nginx/nginx.prod.conf.template ]; then TMPL=/etc/nginx/nginx.prod.conf.template; else TMPL=/etc/nginx/nginx.conf.template; fi; envsubst '$$INTERNAL_TOKEN' < \"$TMPL\" > /etc/nginx/nginx.conf && exec nginx -g 'daemon off;'"] diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf index 771b094..e47a1d0 100644 --- a/deploy/nginx/nginx.conf +++ b/deploy/nginx/nginx.conf @@ -15,7 +15,8 @@ http { vod_metadata_cache metadata_cache 512m; vod_mapping_cache mapping_cache 5m; - vod_response_cache response_cache 128m; + vod_response_cache response_cache 512m; + vod_performance_counters perf_counters 1m; vod_max_mapping_response_size 16k; vod_max_upstream_headers_size 4k; vod_last_modified_types *; diff --git a/deploy/nginx/nginx.prod.conf b/deploy/nginx/nginx.prod.conf index 3b88773..744b3a2 100644 --- a/deploy/nginx/nginx.prod.conf +++ b/deploy/nginx/nginx.prod.conf @@ -15,7 +15,8 @@ http { vod_metadata_cache metadata_cache 512m; vod_mapping_cache mapping_cache 5m; - vod_response_cache response_cache 128m; + vod_response_cache response_cache 512m; + vod_performance_counters perf_counters 1m; vod_max_mapping_response_size 16k; vod_max_upstream_headers_size 4k; vod_last_modified_types *; @@ -49,6 +50,7 @@ http { internal; proxy_pass http://metadata:8002/internal/videos/$video_id/vod; proxy_set_header Host metadata; + proxy_set_header X-Internal-Token "${INTERNAL_TOKEN}"; } location ^~ /minio/ { From 3ab051061c6428ba04161ab29b53c50f381fcc76 Mon Sep 17 00:00:00 2001 From: SlavaKuntsov Date: Mon, 31 Aug 2026 22:41:15 +0300 Subject: [PATCH 4/4] feat(frontend): handle private HLS token and visibility switch Add visibility to Video type, getHlsUrl token param, getHlsToken and updateVideo helpers, VideoPlayer xhrSetup sends Authorization and handles token, watch page fetches signed URL for private owner videos and adds visibility selector. --- frontend/src/app/watch/[id]/page.tsx | 48 ++++++++++++++++++++++--- frontend/src/components/VideoPlayer.tsx | 28 ++++++++++++++- frontend/src/lib/api.ts | 18 +++++++--- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/watch/[id]/page.tsx b/frontend/src/app/watch/[id]/page.tsx index 5c145af..8697209 100644 --- a/frontend/src/app/watch/[id]/page.tsx +++ b/frontend/src/app/watch/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; import VideoPlayer from "@/components/VideoPlayer"; -import { deleteVideo, getHlsUrl, getVideo, type Video } from "@/lib/api"; +import { deleteVideo, getHlsUrl, getHlsToken, getVideo, updateVideo, type Video } from "@/lib/api"; import { useAuth } from "@/store/auth"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -21,6 +21,8 @@ export default function WatchPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [deleting, setDeleting] = useState(false); + const [hlsSrc, setHlsSrc] = useState(null); + const [visibility, setVisibility] = useState("public"); useEffect(() => { if (!id) return; @@ -49,6 +51,26 @@ export default function WatchPage() { }; }, [id]); + const hlsReady = video?.status === "ready"; + const isOwner = !!(userId && video && video.owner_id === userId); + + useEffect(() => { + if (!video) return; + setVisibility(video.visibility || "public"); + if (!hlsReady) { + setHlsSrc(null); + return; + } + if (video.visibility === "private" && isOwner) { + getHlsToken(video.id) + .then((t) => setHlsSrc(getHlsUrl(video.id, t.token))) + .catch(() => setHlsSrc(getHlsUrl(video.id))); + } else { + setHlsSrc(getHlsUrl(video.id)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [video?.id, video?.visibility, hlsReady, isOwner]); + if (loading) return (
@@ -71,9 +93,6 @@ export default function WatchPage() {
); - const hlsReady = video.status === "ready"; - const isOwner = userId && video.owner_id === userId; - const handleDelete = async () => { if (!confirm(`Удалить "${video.title}"? Это удалит все файлы безвозвратно.`)) return; setDeleting(true); @@ -86,11 +105,21 @@ export default function WatchPage() { } }; + const handleVisibility = async (v: string) => { + try { + const upd = await updateVideo(video.id, { visibility: v as any }); + setVideo(upd); + setVisibility(upd.visibility || v); + } catch (e) { + alert((e as Error).message); + } + }; + return (
{hlsReady ? ( - + hlsSrc ? :
Loading HLS…
) : (
{video.status === "processing" || video.status === "uploaded" ? ( @@ -122,7 +151,16 @@ export default function WatchPage() { {video.duration && {video.duration}s} {new Date(video.created_at).toLocaleString()} @{video.owner_email || video.owner_id.slice(0, 8)}{isOwner ? " · твое" : ""} + visibility: {video.visibility || "public"}
+ {isOwner && ( +
+ Visibility: + {["public", "private", "unlisted"].map((v) => ( + + ))} +
+ )} {!hlsReady && (

HLS will appear at {getHlsUrl(video.id)} once ready. diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 891d9c6..8337f27 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -36,10 +36,36 @@ export default function VideoPlayer({ src, poster }: { src: string; poster?: str return; } + // Extract token from src for private HLS (phase 13) and forward via header for segment fetches + let hlsToken: string | null = null; + try { + const u = new URL(src, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + hlsToken = u.searchParams.get("token"); + } catch {} const hls = new Hls({ enableWorker: true, lowLatencyMode: false, - }); + xhrSetup: (xhr: XMLHttpRequest, url: string) => { + // Attach Authorization for private videos so gateway can verify owner without token param + const access = typeof window !== "undefined" ? localStorage.getItem("access_token") : null; + if (access && !url.includes("token=")) { + xhr.setRequestHeader("Authorization", `Bearer ${access}`); + } + // If master url had token, propagate to segment requests + if (hlsToken && !url.includes("token=")) { + const sep = url.includes("?") ? "&" : "?"; + // xhr URL cannot be rewritten here directly, but we can set header fallback + // Instead we override by opening new url — hls.js allows xhr.open override via url param mutation before send + // Workaround: if token present, add as header alternative (gateway checks query OR header) + // Gateway HLSAuth checks ?token= and Authorization, so header is sufficient. + if (access) { + // already set + } else { + xhr.setRequestHeader("X-HLS-Token", hlsToken); + } + } + }, + } as any); hlsRef.current = hls; hls.loadSource(src); hls.attachMedia(video); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d4b163e..779a80a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -12,14 +12,19 @@ function hlsBase(): string { return process.env.GATEWAY_URL?.replace(/\/$/, "") || API_BASE || "http://localhost:8080"; } -export function getHlsUrl(videoId: string): string { +export function getHlsUrl(videoId: string, token?: string): string { const b = hlsBase(); - // nginx-vod mapped mode serves HLS at /hls/{id}/master.m3u8 (with trailing structure) - // Actual nginx-vod config: location ~ "^/hls/[0-9a-fA-F-]{36}/" with vod hls; expects /hls/{id}/master.m3u8 - return `${b}/hls/${videoId}/master.m3u8`; + const base = `${b}/hls/${videoId}/master.m3u8`; + if (token) return `${base}?token=${encodeURIComponent(token)}`; + return base; +} + +export async function getHlsToken(videoId: string): Promise<{ token: string; expires_in: number; url: string }> { + return request<{ token: string; expires_in: number; url: string }>(`/api/v1/videos/${videoId}/hls-token`); } export type VideoStatus = "uploaded" | "processing" | "ready" | "failed"; +export type Visibility = "public" | "private" | "unlisted"; export interface Rendition { video_id: string; quality: string; @@ -36,6 +41,7 @@ export interface Video { description: string; duration?: number | null; status: VideoStatus; + visibility?: Visibility; thumbnail_s3_key?: string | null; thumbnail_url?: string | null; created_at: string; @@ -123,6 +129,10 @@ export async function deleteVideo(id: string): Promise { return request(`/api/v1/videos/${id}`, { method: "DELETE" }); } +export async function updateVideo(id: string, data: { title?: string; description?: string; visibility?: Visibility }): Promise