Adiciona autenticação opcional, VNC integrado, GPU por stream, proxy HLS e melhorias de segurança
---
- Adicionado sistema de autenticação opcional via AUTH_USER/AUTH_PASS: middleware Next.js, página de login, cookie rolling de
30 dias, timingSafeEqual para comparação segura de credenciais;
- Adicionado proxy HLS em /api/hls/[...path] que roteia para localhost:8888 internamente; player e player-static atualizados
para usar a rota proxy;
- Adicionada página /vnc/[id] integrada na UI (iframe + botão Back com auto-hide), substituindo abertura em nova aba;
- Adicionado campo gpu: boolean por stream; controlado via {{GPU_FLAGS}} no template do Chromium e no reprovision.mjs;
- Ajustado delay da primeira thumbnail para stream.delay + 60 para garantir conclusão do autologin antes da captura;
- Atualizado docker-compose.yml: porta 6080 vinculada a localhost, portas 1935 e 8888 comentadas por padrão;
- Traduzidos todos os comentários de código do português para o inglês;
- Adicionado crédito riguetto.dev no header com underline no hover;
- README e CLAUDE.md atualizados com arquitetura, portas e features corretas;
---
This commit is contained in:
@@ -18,11 +18,13 @@ All processes are managed by Supervisord. The web UI is a Next.js app that contr
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Stream any URL** — if it loads in a browser, it streams
|
- **Stream any URL** — if it loads in a browser, it streams
|
||||||
- **Dashboard with live thumbnails** — captured from the HLS output, refreshable on demand
|
- **Dashboard with live thumbnails** — captured directly from the Xvfb display, refreshable on demand
|
||||||
- **VNC access** — inspect any stream's virtual display from the browser via unified noVNC (single port, token routing)
|
- **Inline VNC** — inspect any stream's virtual display without leaving the UI (`/vnc/{id}`)
|
||||||
- **Autologin with CDP detection** — configure credentials per stream; on restart, queries Chrome DevTools Protocol to skip login if the session is still alive
|
- **Autologin with CDP detection** — configure credentials per stream; on restart, queries Chrome DevTools Protocol to skip login if the session is still alive
|
||||||
- **Persistent desired state** — streams remember if they were running or stopped and restore automatically on container restart
|
- **Persistent desired state** — streams remember if they were running or stopped and restore automatically on container restart
|
||||||
|
- **Optional authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI; rolling 30-day session, no login required while active
|
||||||
- **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream
|
- **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream
|
||||||
|
- **GPU acceleration** — optional per-stream Chromium GPU flag (disabled by default for container compatibility)
|
||||||
- **Built-in HLS player** — watch any stream in the browser; also serves a standalone embeddable HTML page per stream
|
- **Built-in HLS player** — watch any stream in the browser; also serves a standalone embeddable HTML page per stream
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -39,11 +41,13 @@ services:
|
|||||||
- seccomp:unconfined # required for Chromium syscalls
|
- seccomp:unconfined # required for Chromium syscalls
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Sao_Paulo
|
TZ: America/Sao_Paulo
|
||||||
|
# AUTH_USER: admin # optional: enables login if both are set
|
||||||
|
# AUTH_PASS: secure_password
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000" # Web UI
|
- "3000:3000" # Web UI — main entry point
|
||||||
- "1935:1935" # RTMP input
|
- "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN
|
||||||
- "8888:8888" # HLS output
|
# - "1935:1935" # RTMP — expose only for external ingest (e.g. OBS)
|
||||||
- "6080:6080" # noVNC
|
# - "8888:8888" # HLS — internal only; proxied through the UI at /api/hls/
|
||||||
volumes:
|
volumes:
|
||||||
- decap-stream:/app/data
|
- decap-stream:/app/data
|
||||||
|
|
||||||
@@ -63,12 +67,12 @@ Open **http://localhost:3000** and add your first stream.
|
|||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
| Port | Service |
|
| Port | Default | Description |
|
||||||
|------|---------|
|
|------|---------|-------------|
|
||||||
| `3000` | Web UI (Next.js) |
|
| `3000` | exposed | Web UI (Next.js) — sole public entry point |
|
||||||
| `1935` | RTMP ingest (MediaMTX) |
|
| `6080` | localhost only | noVNC (token-based routing to all streams) |
|
||||||
| `8888` | HLS output (MediaMTX) |
|
| `1935` | commented out | RTMP ingest (MediaMTX) — only needed for external ingest |
|
||||||
| `6080` | noVNC unified (token-based routing to all streams) |
|
| `8888` | commented out | HLS output (MediaMTX) — proxied through Next.js at `/api/hls/` |
|
||||||
|
|
||||||
## RTMP & HLS URLs
|
## RTMP & HLS URLs
|
||||||
|
|
||||||
@@ -77,8 +81,8 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
|
|||||||
| Protocol | URL |
|
| Protocol | URL |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| RTMP ingest | `rtmp://<host>:1935/live/<id>` |
|
| RTMP ingest | `rtmp://<host>:1935/live/<id>` |
|
||||||
| HLS manifest | `http://<host>:8888/live/<id>/index.m3u8` |
|
| HLS manifest | `http://<host>:3000/api/hls/live/<id>/index.m3u8` |
|
||||||
| VNC | `http://<host>:6080/vnc.html?autoconnect=true&path=websockify%3Ftoken%3D<id>` |
|
| VNC (inline) | `http://<host>:3000/vnc/<id>` |
|
||||||
|
|
||||||
## Stream Configuration
|
## Stream Configuration
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
|
|||||||
| `name` | | Display name |
|
| `name` | | Display name |
|
||||||
| `url` | | URL to open in Chromium |
|
| `url` | | URL to open in Chromium |
|
||||||
| `user` / `pass` | | Credentials for autologin (optional) |
|
| `user` / `pass` | | Credentials for autologin (optional) |
|
||||||
| `delay` | `15s` | Seconds before ffmpeg starts (allows page to load) |
|
| `delay` | `15s` | Seconds before ffmpeg starts (allows page to load; also offsets first thumbnail) |
|
||||||
| `resolution` | `1920x1080` | Virtual display and capture size |
|
| `resolution` | `1920x1080` | Virtual display and capture size |
|
||||||
| `scale` | `1280x720` | Output video resolution |
|
| `scale` | `1280x720` | Output video resolution |
|
||||||
| `fps` | `30` | Capture framerate |
|
| `fps` | `30` | Capture framerate |
|
||||||
@@ -98,15 +102,17 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
|
|||||||
| `tune` | `stillimage` | x264 tune (`stillimage` for dashboards, `zerolatency` for dynamic content) |
|
| `tune` | `stillimage` | x264 tune (`stillimage` for dashboards, `zerolatency` for dynamic content) |
|
||||||
| `gop` | `60` | Keyframe interval (auto-calculated as 2× FPS in the UI) |
|
| `gop` | `60` | Keyframe interval (auto-calculated as 2× FPS in the UI) |
|
||||||
| `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) |
|
| `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) |
|
||||||
|
| `gpu` | `false` | Enable Chromium GPU acceleration (requires host GPU + container access) |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Container │
|
│ Container │
|
||||||
│ │
|
│ │
|
||||||
│ Next.js :3000 ──API──► Supervisord │
|
│ Next.js :3000 ──API──► Supervisord │
|
||||||
│ ├── novnc :6080 (global) │
|
│ ├── /api/hls/ ──────► MediaMTX :8888 (internal) │
|
||||||
|
│ └── /vnc/{id} ──────► noVNC :6080 (localhost) │
|
||||||
│ └── per stream: │
|
│ └── per stream: │
|
||||||
│ ├── xvfb (display) │
|
│ ├── xvfb (display) │
|
||||||
│ ├── chromium (browser) │
|
│ ├── chromium (browser) │
|
||||||
@@ -115,7 +121,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
|
|||||||
│ └── ffmpeg (encode) │
|
│ └── ffmpeg (encode) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ MediaMTX :1935/:8888 ◄────RTMP────────┘ │
|
│ MediaMTX :1935/:8888 ◄────RTMP────────┘ │
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `streams.json` flat file + one directory per stream under `/app/data/streams/{id}/`
|
- `streams.json` flat file + one directory per stream under `/app/data/streams/{id}/`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
DEFAULT_IMAGE="git.kralot.cloud/kralot/decap-stream"
|
DEFAULT_IMAGE="registry.kralot.cloud/kralot/decap-stream"
|
||||||
DEFAULT_VERSION="0.0.0"
|
DEFAULT_VERSION="0.0.0"
|
||||||
DEFAULT_LATEST="latest"
|
DEFAULT_LATEST="latest"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
IMAGE ?= git.kralot.cloud/kralot/decap-stream
|
IMAGE ?= registry.kralot.cloud/kralot/decap-stream
|
||||||
TAG ?= ""
|
TAG ?= ""
|
||||||
|
|
||||||
.PHONY: build push
|
.PHONY: build push
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
decap-stream:
|
decap-stream:
|
||||||
image: git.kralot.cloud/kralot/decap-stream:latest
|
image: ghcr.io/riguettodev/decap-stream:latest
|
||||||
container_name: decap-stream
|
container_name: decap-stream
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
shm_size: "2gb"
|
shm_size: "1gb"
|
||||||
security_opt:
|
security_opt:
|
||||||
- seccomp:unconfined
|
- seccomp:unconfined
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Sao_Paulo
|
TZ: America/Sao_Paulo
|
||||||
|
# AUTH_USER: admin # Se definido (junto com AUTH_PASS), habilita login
|
||||||
|
# AUTH_PASS: secure_password
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000" # Web UI
|
- "3000:3000" # Web UI — main entry point
|
||||||
- "1935:1935" # RTMP (MediaMTX)
|
- "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN
|
||||||
- "8888:8888" # HLS (MediaMTX)
|
# - "1935:1935" # RTMP — internal only; expose only for external ingest (e.g. OBS)
|
||||||
- "6080:6080" # VNC (noVNC)
|
# - "8888:8888" # HLS — internal only; proxied through Next.js at /api/hls/
|
||||||
volumes:
|
volumes:
|
||||||
- streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs
|
- streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs
|
||||||
# - logs:/app/data/logs # Optional
|
# - logs:/app/data/logs # Optional
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ for (const stream of streams) {
|
|||||||
THREADS: stream.threads ?? 0,
|
THREADS: stream.threads ?? 0,
|
||||||
USER: stream.user ?? '',
|
USER: stream.user ?? '',
|
||||||
PASS: stream.pass ?? '',
|
PASS: stream.pass ?? '',
|
||||||
|
GPU_FLAGS: stream.gpu ? '' : ' --disable-gpu \\\n',
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(path.join(dir, 'stream.conf'), render(confTpl, vars), 'utf-8')
|
fs.writeFileSync(path.join(dir, 'stream.conf'), render(confTpl, vars), 'utf-8')
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ command=bash -c "rm -rf \
|
|||||||
&& chromium \
|
&& chromium \
|
||||||
--no-sandbox \
|
--no-sandbox \
|
||||||
--test-type \
|
--test-type \
|
||||||
--disable-gpu \
|
{{GPU_FLAGS}} --window-size={{CHROME_SIZE}} \
|
||||||
--window-size={{CHROME_SIZE}} \
|
|
||||||
--start-fullscreen \
|
--start-fullscreen \
|
||||||
--user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \
|
--user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \
|
||||||
--no-first-run \
|
--no-first-run \
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import { AUTH_ENABLED, COOKIE_NAME, computeSessionToken } from "@/lib/auth"
|
||||||
|
|
||||||
|
function hash(s: string) {
|
||||||
|
return crypto.createHash("sha256").update(s).digest()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!AUTH_ENABLED) {
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: string, pass: string
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
user = String(body.user ?? "")
|
||||||
|
pass = String(body.pass ?? "")
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userOk = crypto.timingSafeEqual(hash(user), hash(process.env.AUTH_USER!))
|
||||||
|
const passOk = crypto.timingSafeEqual(hash(pass), hash(process.env.AUTH_PASS!))
|
||||||
|
if (!userOk || !passOk) throw new Error()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await computeSessionToken()
|
||||||
|
const res = NextResponse.json({ ok: true })
|
||||||
|
res.cookies.set(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 days — auto-renewed on every request (rolling session)
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { COOKIE_NAME } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const res = NextResponse.json({ ok: true })
|
||||||
|
res.cookies.delete(COOKIE_NAME)
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { AUTH_ENABLED } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ enabled: AUTH_ENABLED })
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ path: string[] }> }
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: Ctx) {
|
||||||
|
const { path } = await params
|
||||||
|
const upstream = `http://localhost:8888/${path.join("/")}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(upstream, {
|
||||||
|
headers: { Accept: req.headers.get("Accept") ?? "*/*" },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) return new NextResponse(null, { status: res.status })
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
const ct = res.headers.get("content-type")
|
||||||
|
if (ct) headers.set("content-type", ct)
|
||||||
|
headers.set("cache-control", "no-cache")
|
||||||
|
|
||||||
|
return new NextResponse(res.body, { status: 200, headers })
|
||||||
|
} catch {
|
||||||
|
return new NextResponse(null, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,15 @@ export async function POST(_req: Request, { params }: Ctx) {
|
|||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "start":
|
case "start":
|
||||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
|
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() })
|
||||||
startStream(id)
|
startStream(id)
|
||||||
break
|
break
|
||||||
case "stop":
|
case "stop":
|
||||||
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) // #19
|
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() })
|
||||||
stopStream(id)
|
stopStream(id)
|
||||||
break
|
break
|
||||||
case "restart":
|
case "restart":
|
||||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
|
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() })
|
||||||
restartStream(id)
|
restartStream(id)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function PATCH(req: Request, { params }: Ctx) {
|
|||||||
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
|
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||||
|
|
||||||
const body = (await req.json()) as StreamUpdate
|
const body = (await req.json()) as StreamUpdate
|
||||||
// id e portas não podem ser alterados via PATCH
|
// id and ports are immutable — strip them from PATCH body
|
||||||
const { id: _id, ...safe } = body as StreamUpdate & { id?: string }
|
const { id: _id, ...safe } = body as StreamUpdate & { id?: string }
|
||||||
void _id
|
void _id
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export async function POST(req: Request) {
|
|||||||
const stream = {
|
const stream = {
|
||||||
...STREAM_DEFAULTS,
|
...STREAM_DEFAULTS,
|
||||||
...body,
|
...body,
|
||||||
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
|
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale),
|
||||||
...ports,
|
...ports,
|
||||||
desiredState: "running" as const, // #19
|
desiredState: "running" as const,
|
||||||
order: nextOrder,
|
order: nextOrder,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -40,7 +40,7 @@ export async function POST(req: Request) {
|
|||||||
saveStream(stream)
|
saveStream(stream)
|
||||||
provisionStream(stream)
|
provisionStream(stream)
|
||||||
startStream(stream.id)
|
startStream(stream.id)
|
||||||
captureThumb(stream.id, 60)
|
captureThumb(stream.id, stream.delay + 60)
|
||||||
|
|
||||||
return NextResponse.json(stream, { status: 201 })
|
return NextResponse.json(stream, { status: 201 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const from = searchParams.get("from") ?? "/"
|
||||||
|
|
||||||
|
const [user, setUser] = useState("")
|
||||||
|
const [pass, setPass] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ user, pass }),
|
||||||
|
})
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push(from)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
setError("Invalid credentials")
|
||||||
|
setPass("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass = "w-full rounded border border-[#222] bg-[#1a1a1a] px-3 py-2 text-sm text-[#ededed] outline-none focus:ring-1 focus:ring-[#444] transition-colors placeholder:text-[#555]"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={{ background: "#0a0a0a" }}>
|
||||||
|
<div className="w-80 rounded-xl border border-[#222] shadow-2xl p-6 flex flex-col gap-5" style={{ background: "#111" }}>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<img src="/web-app-manifest-192x192.png" alt="" className="w-5 h-5 rounded" />
|
||||||
|
<span className="font-semibold text-sm text-[#ededed]">Decap Stream</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#888]">Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
value={user}
|
||||||
|
onChange={e => setUser(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={pass}
|
||||||
|
onChange={e => setPass(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500 font-medium">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !user || !pass}
|
||||||
|
className="w-full rounded border border-[#ededed] bg-[#ededed] text-[#0a0a0a] text-sm font-medium py-2 hover:bg-transparent hover:text-[#ededed] transition-colors disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer mt-1"
|
||||||
|
>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ background: "#0a0a0a" }} className="min-h-screen" />}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
+21
-6
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react"
|
import { useEffect, useState, useCallback } from "react"
|
||||||
import { Plus, Download, RefreshCw, Settings, X } from "lucide-react"
|
import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react"
|
||||||
import { StreamCard } from "@/components/StreamCard"
|
import { StreamCard } from "@/components/StreamCard"
|
||||||
import type { Stream } from "@/types/stream"
|
import type { Stream } from "@/types/stream"
|
||||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
|
||||||
@@ -54,7 +54,7 @@ function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// #7 — settings popup
|
// settings popup
|
||||||
function SettingsPopup({ cardSize, onCardSize, onClose }: {
|
function SettingsPopup({ cardSize, onCardSize, onClose }: {
|
||||||
cardSize: CardSize
|
cardSize: CardSize
|
||||||
onCardSize: (s: CardSize) => void
|
onCardSize: (s: CardSize) => void
|
||||||
@@ -101,6 +101,7 @@ export default function GalleryPage() {
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [cardSize, setCardSize] = useState<CardSize>("md") // md = Medium = antigo Big
|
const [cardSize, setCardSize] = useState<CardSize>("md") // md = Medium = antigo Big
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||||
|
|
||||||
@@ -144,7 +145,10 @@ export default function GalleryPage() {
|
|||||||
setStatuses(Object.fromEntries(results))
|
setStatuses(Object.fromEntries(results))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { fetchStreams() }, [fetchStreams])
|
useEffect(() => {
|
||||||
|
fetchStreams()
|
||||||
|
fetch("/api/auth/status").then(r => r.json()).then(d => setAuthEnabled(d.enabled))
|
||||||
|
}, [fetchStreams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (streams.length === 0) return
|
if (streams.length === 0) return
|
||||||
@@ -163,7 +167,7 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
const showSkeleton = loading || refreshing
|
const showSkeleton = loading || refreshing
|
||||||
|
|
||||||
// #6 — todos os botões do header com mesmo padding e tamanho
|
// all header buttons share the same padding and height
|
||||||
const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
||||||
const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer"
|
const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer"
|
||||||
|
|
||||||
@@ -173,9 +177,11 @@ export default function GalleryPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded" />
|
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded" />
|
||||||
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
|
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
|
||||||
|
<span className="text-muted-foreground/40 text-sm select-none">·</span>
|
||||||
|
<a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors">riguetto.dev</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* #6 — refresh com h-8 explícito igual aos outros */}
|
{/* explicit h-8 to match other header buttons */}
|
||||||
<button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
|
<button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -189,10 +195,19 @@ export default function GalleryPage() {
|
|||||||
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
|
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
|
||||||
<Plus className="w-3.5 h-3.5" /> New stream
|
<Plus className="w-3.5 h-3.5" /> New stream
|
||||||
</button>
|
</button>
|
||||||
|
{authEnabled && (
|
||||||
|
<button
|
||||||
|
onClick={async () => { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login" }}
|
||||||
|
className={btnBase}
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* #7 — popup de configurações */}
|
{/* settings popup */}
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<SettingsPopup
|
<SettingsPopup
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { type NextRequest, NextResponse } from "next/server"
|
|||||||
|
|
||||||
type Ctx = { params: Promise<{ id: string }> }
|
type Ctx = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: Ctx) {
|
export async function GET(_req: NextRequest, { params }: Ctx) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const host = req.headers.get("host")?.split(":")[0] ?? "localhost"
|
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -27,7 +26,7 @@ export async function GET(req: NextRequest, { params }: Ctx) {
|
|||||||
<div id="msg"></div>
|
<div id="msg"></div>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
var src='http://${host}:8888/live/${id}/index.m3u8';
|
var src='/api/hls/live/${id}/index.m3u8';
|
||||||
var hls;
|
var hls;
|
||||||
function showMsg(t){
|
function showMsg(t){
|
||||||
var m=document.getElementById('msg');
|
var m=document.getElementById('msg');
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function BackButton({ onClick }: { onClick: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLS e M3U8 usam a mesma lógica — HLS.js carregado inline via fetch, não via <Script>
|
// HLS.js loaded inline via script tag injection to avoid SSR issues with next/script
|
||||||
function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
|
function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const hlsRef = useRef<any>(null)
|
const hlsRef = useRef<any>(null)
|
||||||
@@ -78,7 +78,7 @@ function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
|
|||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
|
|
||||||
// Carrega HLS.js dinamicamente via import para evitar problemas com <Script>
|
// dynamically inject HLS.js to avoid issues with next/script in client components
|
||||||
const script = document.createElement("script")
|
const script = document.createElement("script")
|
||||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"
|
script.src = "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
@@ -123,9 +123,7 @@ function PlayerInner() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const mode = (searchParams.get("mode") ?? "hls") as Mode
|
const mode = (searchParams.get("mode") ?? "hls") as Mode
|
||||||
const host = typeof window !== "undefined" ? window.location.hostname : "localhost"
|
const streamSrc = `/api/hls/live/${id}/index.m3u8`
|
||||||
|
|
||||||
const streamSrc = `http://${host}:8888/live/${id}/index.m3u8`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-black w-screen h-screen overflow-hidden">
|
<div className="relative bg-black w-screen h-screen overflow-hidden">
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
|
function BackButton({ onClick }: { onClick: () => void }) {
|
||||||
|
const [visible, setVisible] = useState(true)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
setVisible(true)
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => setVisible(false), 5000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
show()
|
||||||
|
window.addEventListener("mousemove", show)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", show)
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [show])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ opacity: visible ? 1 : 0, transition: "opacity 0.4s" }}
|
||||||
|
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white bg-black/40 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VncInner() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const host = typeof window !== "undefined" ? window.location.hostname : "localhost"
|
||||||
|
const token = encodeURIComponent(`token=${id}`)
|
||||||
|
const vncUrl = `http://${host}:6080/vnc.html?autoconnect=true&path=websockify%3F${token}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-black w-screen h-screen overflow-hidden">
|
||||||
|
<BackButton onClick={() => router.push("/")} />
|
||||||
|
<iframe src={vncUrl} className="w-screen h-screen border-0" allowFullScreen />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VncPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="bg-black w-screen h-screen" />}>
|
||||||
|
<VncInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -125,8 +125,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openVNC() {
|
function openVNC() {
|
||||||
const token = encodeURIComponent(`token=${stream.id}`)
|
window.location.href = `/vnc/${stream.id}`
|
||||||
window.open(`http://${window.location.hostname}:6080/vnc.html?autoconnect=true&path=websockify%3F${token}`, "_blank")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyRTMP() {
|
function copyRTMP() {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const TOOLTIPS = {
|
|||||||
delay: "Seconds to wait after Chromium starts before ffmpeg begins capturing. Gives the page time to fully load and render.",
|
delay: "Seconds to wait after Chromium starts before ffmpeg begins capturing. Gives the page time to fully load and render.",
|
||||||
gop: "Keyframe interval in frames. Recommended: 2× FPS. Affects HLS segment alignment and seek accuracy. Auto-calculated from FPS unless manually changed.",
|
gop: "Keyframe interval in frames. Recommended: 2× FPS. Affects HLS segment alignment and seek accuracy. Auto-calculated from FPS unless manually changed.",
|
||||||
threads: "Number of ffmpeg encoding threads. 0 = auto-detect (recommended). Increasing this can reduce latency on multi-core systems at the cost of slightly reduced compression efficiency.",
|
threads: "Number of ffmpeg encoding threads. 0 = auto-detect (recommended). Increasing this can reduce latency on multi-core systems at the cost of slightly reduced compression efficiency.",
|
||||||
|
gpu: "Enable GPU acceleration in Chromium. Disabled by default because most container environments lack GPU access. Enable only if the host has a compatible GPU and the container has access to it.",
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({ text }: { text: string }) {
|
function Tooltip({ text }: { text: string }) {
|
||||||
@@ -101,6 +102,7 @@ export function StreamForm({ initial }: Props) {
|
|||||||
tune: initial.tune,
|
tune: initial.tune,
|
||||||
gop: initial.gop,
|
gop: initial.gop,
|
||||||
threads: initial.threads ?? 0,
|
threads: initial.threads ?? 0,
|
||||||
|
gpu: initial.gpu ?? false,
|
||||||
} : {}),
|
} : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ export function StreamForm({ initial }: Props) {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
function set(key: keyof StreamCreate, value: string | number) {
|
function set(key: keyof StreamCreate, value: string | number | boolean) {
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
setErrors((e) => { const n = { ...e }; delete n[key as string]; return n })
|
setErrors((e) => { const n = { ...e }; delete n[key as string]; return n })
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,27 @@ export function StreamForm({ initial }: Props) {
|
|||||||
<Input type="number" min={0} value={form.threads ?? 0} onChange={(e) => set("threads", Number(e.target.value))} />
|
<Input type="number" min={0} value={form.threads ?? 0} onChange={(e) => set("threads", Number(e.target.value))} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={form.gpu ?? false}
|
||||||
|
onClick={() => set("gpu", !form.gpu)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none",
|
||||||
|
form.gpu ? "bg-primary" : "bg-zinc-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg transform transition-transform",
|
||||||
|
form.gpu ? "translate-x-4" : "translate-x-0"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">GPU acceleration (Chromium)</span>
|
||||||
|
<Tooltip text={TOOLTIPS.gpu} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Edge-compatible — no Node.js imports here (used in middleware)
|
||||||
|
|
||||||
|
export const AUTH_ENABLED = !!(process.env.AUTH_USER && process.env.AUTH_PASS)
|
||||||
|
export const COOKIE_NAME = "ds_session"
|
||||||
|
|
||||||
|
// HMAC-SHA256(user, key=pass) — deterministic, no in-memory state, survives restarts
|
||||||
|
// Works in both Edge (SubtleCrypto) and Node.js runtime
|
||||||
|
export async function computeSessionToken(): Promise<string> {
|
||||||
|
const user = process.env.AUTH_USER ?? ""
|
||||||
|
const pass = process.env.AUTH_PASS ?? ""
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const key = await globalThis.crypto.subtle.importKey(
|
||||||
|
"raw", enc.encode(pass),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false, ["sign"]
|
||||||
|
)
|
||||||
|
const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(user))
|
||||||
|
return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, "0")).join("")
|
||||||
|
}
|
||||||
+2
-2
@@ -13,7 +13,7 @@ function ensureFile() {
|
|||||||
export function readStreams(): Stream[] {
|
export function readStreams(): Stream[] {
|
||||||
ensureFile()
|
ensureFile()
|
||||||
const streams = JSON.parse(fs.readFileSync(STREAMS_FILE, "utf-8")) as Stream[]
|
const streams = JSON.parse(fs.readFileSync(STREAMS_FILE, "utf-8")) as Stream[]
|
||||||
// migrate: assign order to streams that don't have it yet
|
// migration: assign order to streams that don't have it yet
|
||||||
let dirty = false
|
let dirty = false
|
||||||
streams.forEach((s, i) => {
|
streams.forEach((s, i) => {
|
||||||
if (s.order === undefined) { s.order = i; dirty = true }
|
if (s.order === undefined) { s.order = i; dirty = true }
|
||||||
@@ -43,7 +43,7 @@ export function deleteStream(id: string): void {
|
|||||||
writeStreams(readStreams().filter((s) => s.id !== id))
|
writeStreams(readStreams().filter((s) => s.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aloca display, portas VNC, noVNC e debug sem conflito com streams existentes
|
// Allocates display number and VNC/debug ports without conflicting with existing streams
|
||||||
export function allocatePorts(): {
|
export function allocatePorts(): {
|
||||||
display: string
|
display: string
|
||||||
vncPort: number
|
vncPort: number
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ function supervisorctl(cmd: string) {
|
|||||||
try {
|
try {
|
||||||
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
|
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
|
||||||
} catch {
|
} catch {
|
||||||
// supervisorctl retorna exit 1 em alguns casos não-fatais
|
// supervisorctl returns exit 1 in some non-fatal cases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #6 — converte "1920x1080" → "1920,1080" para o Chrome
|
// converts "1920x1080" → "1920,1080" for Chrome --window-size flag
|
||||||
function resolutionToChrome(res: string): string {
|
function resolutionToChrome(res: string): string {
|
||||||
return res.replace("x", ",")
|
return res.replace("x", ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
// #13 — normaliza scale: aceita "1280x720" ou "1280:720", sempre salva "1280:720"
|
// normalizes scale: accepts "1280x720" or "1280:720", always saves as "1280:720"
|
||||||
export function normalizeScale(scale: string): string {
|
export function normalizeScale(scale: string): string {
|
||||||
return scale.replace("x", ":")
|
return scale.replace("x", ":")
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@ export function provisionStream(stream: Stream): void {
|
|||||||
THREADS: stream.threads ?? 0,
|
THREADS: stream.threads ?? 0,
|
||||||
USER: stream.user ?? "",
|
USER: stream.user ?? "",
|
||||||
PASS: stream.pass ?? "",
|
PASS: stream.pass ?? "",
|
||||||
|
GPU_FLAGS: stream.gpu ? "" : " --disable-gpu \\\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
|
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import { AUTH_ENABLED, COOKIE_NAME, computeSessionToken } from "@/lib/auth"
|
||||||
|
|
||||||
|
const PUBLIC = ["/login", "/api/auth/login"]
|
||||||
|
const PUBLIC_PREFIX = ["/_next/", "/favicon", "/icon", "/apple-touch", "/web-app-manifest"]
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
if (!AUTH_ENABLED) return NextResponse.next()
|
||||||
|
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
if (PUBLIC.includes(pathname) || PUBLIC_PREFIX.some(p => pathname.startsWith(p))) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.cookies.get(COOKIE_NAME)?.value
|
||||||
|
const expected = await computeSessionToken()
|
||||||
|
|
||||||
|
if (!cookie || cookie !== expected) {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = "/login"
|
||||||
|
url.searchParams.set("from", pathname)
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rolling session — refresh cookie on every request, resetting the 30-day timer
|
||||||
|
const res = NextResponse.next()
|
||||||
|
res.cookies.set(COOKIE_NAME, cookie, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image).*)"],
|
||||||
|
}
|
||||||
+7
-4
@@ -7,9 +7,9 @@ export interface Stream {
|
|||||||
user?: string
|
user?: string
|
||||||
pass?: string
|
pass?: string
|
||||||
|
|
||||||
delay: number // segundos antes do ffmpeg iniciar (delay de boot da stream)
|
delay: number // seconds before ffmpeg starts (stream boot delay)
|
||||||
resolution: string // tamanho do Xvfb/Chrome: "1920x1080"
|
resolution: string // Xvfb/Chrome window size: "1920x1080"
|
||||||
scale: string // scale do ffmpeg output: "1280x720" (convertido para "1280:720" internamente)
|
scale: string // ffmpeg output scale: "1280x720" (stored as "1280:720" internally)
|
||||||
fps: number
|
fps: number
|
||||||
bitrate: string
|
bitrate: string
|
||||||
bufsize: string
|
bufsize: string
|
||||||
@@ -22,7 +22,9 @@ export interface Stream {
|
|||||||
vncPort: number
|
vncPort: number
|
||||||
debugPort: number
|
debugPort: number
|
||||||
|
|
||||||
desiredState: "running" | "stopped" // #19 — estado desejado persistente
|
gpu: boolean
|
||||||
|
|
||||||
|
desiredState: "running" | "stopped" // persisted desired state, restored on container restart
|
||||||
|
|
||||||
order: number
|
order: number
|
||||||
|
|
||||||
@@ -44,4 +46,5 @@ export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = {
|
|||||||
tune: "stillimage",
|
tune: "stillimage",
|
||||||
gop: 60,
|
gop: 60,
|
||||||
threads: 0,
|
threads: 0,
|
||||||
|
gpu: false,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user