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/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/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/ { 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/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