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:
@@ -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) {
|
||||
case "start":
|
||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
|
||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() })
|
||||
startStream(id)
|
||||
break
|
||||
case "stop":
|
||||
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) // #19
|
||||
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() })
|
||||
stopStream(id)
|
||||
break
|
||||
case "restart":
|
||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
|
||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() })
|
||||
restartStream(id)
|
||||
break
|
||||
default:
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function PATCH(req: Request, { params }: Ctx) {
|
||||
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||
|
||||
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 }
|
||||
void _id
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ export async function POST(req: Request) {
|
||||
const stream = {
|
||||
...STREAM_DEFAULTS,
|
||||
...body,
|
||||
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
|
||||
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale),
|
||||
...ports,
|
||||
desiredState: "running" as const, // #19
|
||||
desiredState: "running" as const,
|
||||
order: nextOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -40,7 +40,7 @@ export async function POST(req: Request) {
|
||||
saveStream(stream)
|
||||
provisionStream(stream)
|
||||
startStream(stream.id)
|
||||
captureThumb(stream.id, 60)
|
||||
captureThumb(stream.id, stream.delay + 60)
|
||||
|
||||
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"
|
||||
|
||||
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 type { Stream } from "@/types/stream"
|
||||
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 }: {
|
||||
cardSize: CardSize
|
||||
onCardSize: (s: CardSize) => void
|
||||
@@ -101,6 +101,7 @@ export default function GalleryPage() {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [cardSize, setCardSize] = useState<CardSize>("md") // md = Medium = antigo Big
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
|
||||
@@ -144,7 +145,10 @@ export default function GalleryPage() {
|
||||
setStatuses(Object.fromEntries(results))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchStreams() }, [fetchStreams])
|
||||
useEffect(() => {
|
||||
fetchStreams()
|
||||
fetch("/api/auth/status").then(r => r.json()).then(d => setAuthEnabled(d.enabled))
|
||||
}, [fetchStreams])
|
||||
|
||||
useEffect(() => {
|
||||
if (streams.length === 0) return
|
||||
@@ -163,7 +167,7 @@ export default function GalleryPage() {
|
||||
|
||||
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 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">
|
||||
<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>
|
||||
<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 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">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
@@ -189,10 +195,19 @@ export default function GalleryPage() {
|
||||
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
|
||||
<Plus className="w-3.5 h-3.5" /> New stream
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{/* #7 — popup de configurações */}
|
||||
{/* settings popup */}
|
||||
{settingsOpen && (
|
||||
<SettingsPopup
|
||||
cardSize={cardSize}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
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 host = req.headers.get("host")?.split(":")[0] ?? "localhost"
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -27,7 +26,7 @@ export async function GET(req: NextRequest, { params }: Ctx) {
|
||||
<div id="msg"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||
<script>
|
||||
var src='http://${host}:8888/live/${id}/index.m3u8';
|
||||
var src='/api/hls/live/${id}/index.m3u8';
|
||||
var hls;
|
||||
function showMsg(t){
|
||||
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 }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<any>(null)
|
||||
@@ -78,7 +78,7 @@ function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
|
||||
const v = videoRef.current
|
||||
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")
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"
|
||||
script.onload = () => {
|
||||
@@ -123,9 +123,7 @@ function PlayerInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const mode = (searchParams.get("mode") ?? "hls") as Mode
|
||||
const host = typeof window !== "undefined" ? window.location.hostname : "localhost"
|
||||
|
||||
const streamSrc = `http://${host}:8888/live/${id}/index.m3u8`
|
||||
const streamSrc = `/api/hls/live/${id}/index.m3u8`
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user