Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion deploy/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions deploy/migrations/000002_add_visibility.down.sql
Original file line number Diff line number Diff line change
@@ -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 $$;
8 changes: 8 additions & 0 deletions deploy/migrations/000002_add_visibility.up.sql
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion deploy/nginx/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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;'"]
3 changes: 2 additions & 1 deletion deploy/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 *;
Expand Down
4 changes: 3 additions & 1 deletion deploy/nginx/nginx.prod.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 *;
Expand Down Expand Up @@ -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/ {
Expand Down
8 changes: 8 additions & 0 deletions deploy/postgres/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
48 changes: 43 additions & 5 deletions frontend/src/app/watch/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +21,8 @@ export default function WatchPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [hlsSrc, setHlsSrc] = useState<string | null>(null);
const [visibility, setVisibility] = useState<string>("public");

useEffect(() => {
if (!id) return;
Expand Down Expand Up @@ -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 (
<div className="py-20 text-center">
Expand All @@ -71,9 +93,6 @@ export default function WatchPage() {
</div>
);

const hlsReady = video.status === "ready";
const isOwner = userId && video.owner_id === userId;

const handleDelete = async () => {
if (!confirm(`Удалить "${video.title}"? Это удалит все файлы безвозвратно.`)) return;
setDeleting(true);
Expand All @@ -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 (
<div className="flex flex-col gap-6">
<div className="">
{hlsReady ? (
<VideoPlayer src={getHlsUrl(video.id)} />
hlsSrc ? <VideoPlayer src={hlsSrc} /> : <div className="flex aspect-video items-center justify-center rounded-xl border bg-white text-zinc-500">Loading HLS…</div>
) : (
<div className="flex aspect-video items-center justify-center rounded-xl border bg-white text-zinc-500">
{video.status === "processing" || video.status === "uploaded" ? (
Expand Down Expand Up @@ -122,7 +151,16 @@ export default function WatchPage() {
{video.duration && <span className="rounded bg-zinc-100 px-2 py-1">{video.duration}s</span>}
<span className="rounded bg-zinc-100 px-2 py-1">{new Date(video.created_at).toLocaleString()}</span>
<span className={`rounded px-2 py-1 ${isOwner ? "bg-green-100 text-green-700" : "bg-zinc-100"}`}>@{video.owner_email || video.owner_id.slice(0, 8)}{isOwner ? " · твое" : ""}</span>
<span className="rounded bg-zinc-100 px-2 py-1">visibility: {video.visibility || "public"}</span>
</div>
{isOwner && (
<div className="mt-2 flex gap-2 text-xs">
<span className="self-center text-zinc-500">Visibility:</span>
{["public", "private", "unlisted"].map((v) => (
<button key={v} onClick={() => handleVisibility(v)} className={`rounded px-2 py-1 ${visibility === v ? "bg-black text-white" : "bg-zinc-100 hover:bg-zinc-200"}`}>{v}</button>
))}
</div>
)}
{!hlsReady && (
<p className="mt-2 text-xs text-zinc-400">
HLS will appear at <code className="rounded bg-zinc-100 px-1">{getHlsUrl(video.id)}</code> once ready.
Expand Down
28 changes: 27 additions & 1 deletion frontend/src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -123,6 +129,10 @@ export async function deleteVideo(id: string): Promise<void> {
return request<void>(`/api/v1/videos/${id}`, { method: "DELETE" });
}

export async function updateVideo(id: string, data: { title?: string; description?: string; visibility?: Visibility }): Promise<Video> {
return request<Video>(`/api/v1/videos/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
}

// Presigned upload: direct PUT to MinIO (Phase 11) — gateway only creates presign, file bypasses Go proxy
export interface PresignResponse {
id: string;
Expand Down
9 changes: 6 additions & 3 deletions services/gateway/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`
Expand Down
Loading
Loading