add video backend server, fix styling and more #54
+56
-9
@@ -352,6 +352,58 @@ function setOpenOriginal(raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isApiVideoUrl(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return false;
|
||||||
|
if (s.startsWith('/api/video/')) return true;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return u.pathname.startsWith('/api/video/');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVideoSessionUrl(videoSrc) {
|
||||||
|
const s = String(videoSrc || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.startsWith('/')) return '/api/video/session';
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return new URL('/api/video/session', u.origin).toString();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVideoSessionForSource(videoSrc, token) {
|
||||||
|
// Only needed for our protected /api/video/* streams.
|
||||||
|
if (!token) return;
|
||||||
|
if (!isApiVideoUrl(videoSrc)) return;
|
||||||
|
const sessionUrl = toVideoSessionUrl(videoSrc);
|
||||||
|
if (!sessionUrl) return;
|
||||||
|
|
||||||
|
// If sessionUrl is cross-origin, we need credentials so the cookie is stored for that domain.
|
||||||
|
const isCrossOrigin = (() => {
|
||||||
|
try {
|
||||||
|
const u = new URL(sessionUrl, window.location.origin);
|
||||||
|
return u.origin !== window.location.origin;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(sessionUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
||||||
|
...(isCrossOrigin ? { credentials: 'include' } : {}),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function playVodLink(vodLink) {
|
async function playVodLink(vodLink) {
|
||||||
const raw = normalizeVodLink(vodLink);
|
const raw = normalizeVodLink(vodLink);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -520,15 +572,6 @@ window.onload = async function () {
|
|||||||
|
|
||||||
setHeader(movie);
|
setHeader(movie);
|
||||||
|
|
||||||
// Allow <video> to call /api/video/* without Authorization headers by using a cookie.
|
|
||||||
// Best-effort: if it fails, external video_src URLs can still work.
|
|
||||||
try {
|
|
||||||
await fetch('/api/video/session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const resolved = resolveMovieSource(movie);
|
const resolved = resolveMovieSource(movie);
|
||||||
if (!resolved?.src) {
|
if (!resolved?.src) {
|
||||||
setOpenOriginal(null);
|
setOpenOriginal(null);
|
||||||
@@ -538,6 +581,10 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow <video> to call protected /api/video/* sources without Authorization headers by using a cookie.
|
||||||
|
// If the source is on a different origin (Python video host), we call THAT host’s /api/video/session.
|
||||||
|
await ensureVideoSessionForSource(resolved.src, token);
|
||||||
|
|
||||||
if (resolved.isLegacy) {
|
if (resolved.isLegacy) {
|
||||||
// Legacy source: never embed it in the player.
|
// Legacy source: never embed it in the player.
|
||||||
setOpenOriginal(resolved.src);
|
setOpenOriginal(resolved.src);
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@ from urllib.parse import quote
|
|||||||
import jwt
|
import jwt
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ JWT_SECRET = _env_str("JWT_SECRET")
|
|||||||
MONGO_URI = _env_str("MONGO_URI")
|
MONGO_URI = _env_str("MONGO_URI")
|
||||||
VIDEO_FILES_DIR = Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
|
VIDEO_FILES_DIR = Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
|
||||||
COOKIE_MAX_AGE_SECONDS = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h
|
COOKIE_MAX_AGE_SECONDS = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h
|
||||||
|
COOKIE_SAMESITE = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
|
||||||
|
|
||||||
|
|
||||||
def _parse_bearer(authorization: Optional[str]) -> str:
|
def _parse_bearer(authorization: Optional[str]) -> str:
|
||||||
@@ -115,6 +117,25 @@ def _mongo_client() -> MongoClient:
|
|||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# CORS: needed so the app can call the Python host's /api/video/session
|
||||||
|
_cors_origins_raw = _env_str("CORS_ALLOW_ORIGINS", "*")
|
||||||
|
_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
|
||||||
|
_cors_allow_origin_regex = None
|
||||||
|
if len(_cors_origins) == 0 or (len(_cors_origins) == 1 and _cors_origins[0] == "*"):
|
||||||
|
# With credentials, browsers can't accept Access-Control-Allow-Origin: *
|
||||||
|
# Using a regex makes Starlette reflect the requesting Origin instead.
|
||||||
|
_cors_origins = []
|
||||||
|
_cors_allow_origin_regex = ".*"
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_cors_origins,
|
||||||
|
allow_origin_regex=_cors_allow_origin_regex,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/video/session")
|
@app.post("/api/video/session")
|
||||||
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
|
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
|
||||||
@@ -123,14 +144,19 @@ def create_video_session(request: Request, response: Response, authorization: Op
|
|||||||
|
|
||||||
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
|
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
|
||||||
encoded = quote(token, safe="")
|
encoded = quote(token, safe="")
|
||||||
|
samesite = (COOKIE_SAMESITE or "Lax").strip().lower()
|
||||||
|
if samesite not in ("lax", "strict", "none"):
|
||||||
|
samesite = "lax"
|
||||||
|
samesite_attr = "None" if samesite == "none" else ("Strict" if samesite == "strict" else "Lax")
|
||||||
cookie_parts = [
|
cookie_parts = [
|
||||||
f"video_auth={encoded}",
|
f"video_auth={encoded}",
|
||||||
"Path=/api/video",
|
"Path=/api/video",
|
||||||
f"Max-Age={COOKIE_MAX_AGE_SECONDS}",
|
f"Max-Age={COOKIE_MAX_AGE_SECONDS}",
|
||||||
"SameSite=Lax",
|
f"SameSite={samesite_attr}",
|
||||||
"HttpOnly",
|
"HttpOnly",
|
||||||
]
|
]
|
||||||
if secure:
|
# Modern browsers require Secure when SameSite=None
|
||||||
|
if secure or samesite == "none":
|
||||||
cookie_parts.append("Secure")
|
cookie_parts.append("Secure")
|
||||||
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
|
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
|
||||||
response.status_code = 204
|
response.status_code = 204
|
||||||
|
|||||||
Reference in New Issue
Block a user