diff --git a/.env.example b/.env.example index baf28ba..15033b5 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,12 @@ POLL_INTERVAL_MS=60000 SEGMENT_TIME_SEC=900 API_DAEMON_PORT=4001 +# Signs the panel's login session cookies (frontend). Generate with: +# openssl rand -base64 32 +# Required in production (docker-compose fails to start without it) — a +# fixed insecure default is only used for local dev when unset. +SESSION_SECRET= + # Set to a live YouTube URL to bypass the 60s polling loop and start # capturing immediately — useful for testing the pipeline without waiting # for a real scheduled stream. diff --git a/README.md b/README.md index d46c82d..98fcd38 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ docker compose up --build Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health +## Panel Girişi + +Panelin tüm sayfaları giriş gerektirir (middleware ile korunuyor). İlk açılışta +henüz hiç kullanıcı yoksa `/login` otomatik olarak "İlk Yönetici Hesabını +Oluştur" formunu gösterir — kullanıcı adı/şifre girip ilk admin hesabını +oradan oluşturursun. Sonraki kullanıcılar için ayrı bir "kullanıcı ekle" +arayüzü henüz yok (Faz 2). + +`SESSION_SECRET` production'da zorunlu (`openssl rand -base64 32` ile +üretilebilir) — local dev'de boş bırakılırsa güvensiz bir varsayılan +kullanılır, sadece test için. + ## Test / Doğrulama Akışı 1. `FORCE_LIVE_URL=` ile `apps/api-daemon`'ı diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index 26d80ef..9e8c8e9 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -1,20 +1,37 @@ import type { Metadata } from "next"; import Link from "next/link"; import "./globals.css"; +import { getSession } from "../lib/auth"; +import { logout } from "./login/actions"; export const metadata: Metadata = { title: "StreamClipper AI — Panel", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const session = await getSession(); + return ( - + {session && ( + + )}
{children}
diff --git a/apps/frontend/app/login/actions.ts b/apps/frontend/app/login/actions.ts new file mode 100644 index 0000000..07f345d --- /dev/null +++ b/apps/frontend/app/login/actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import bcrypt from "bcryptjs"; +import { prisma } from "@streamclipper/db"; +import { createSessionToken, SESSION_COOKIE_NAME } from "../../lib/auth"; + +const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30; + +async function setSessionCookie(userId: string, username: string) { + const token = await createSessionToken({ sub: userId, username }); + const jar = await cookies(); + jar.set(SESSION_COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: SESSION_MAX_AGE_SEC, + }); +} + +export async function bootstrapAdmin(formData: FormData) { + const username = String(formData.get("username") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + if (!username || password.length < 8) { + throw new Error("Kullanıcı adı gerekli, şifre en az 8 karakter olmalı."); + } + + const existing = await prisma.user.count(); + if (existing > 0) { + redirect("/login"); + } + + const passwordHash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ data: { username, passwordHash } }); + + await setSessionCookie(user.id, user.username); + redirect("/"); +} + +export async function login(formData: FormData) { + const username = String(formData.get("username") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + const user = await prisma.user.findUnique({ where: { username } }); + if (!user || !(await bcrypt.compare(password, user.passwordHash))) { + redirect("/login?error=1"); + } + + await setSessionCookie(user.id, user.username); + redirect("/"); +} + +export async function logout() { + const jar = await cookies(); + jar.delete(SESSION_COOKIE_NAME); + redirect("/login"); +} diff --git a/apps/frontend/app/login/page.tsx b/apps/frontend/app/login/page.tsx new file mode 100644 index 0000000..a9fe6df --- /dev/null +++ b/apps/frontend/app/login/page.tsx @@ -0,0 +1,71 @@ +import { prisma } from "@streamclipper/db"; +import { bootstrapAdmin, login } from "./actions"; + +export const dynamic = "force-dynamic"; + +const fieldStyle = { + background: "var(--bg)", + border: "1px solid var(--border)", + borderRadius: "6px", + padding: "0.6rem 0.7rem", + color: "var(--text)", + fontSize: "0.9rem", +}; + +const buttonStyle = { + background: "var(--accent)", + border: "none", + borderRadius: "6px", + padding: "0.6rem 1rem", + color: "#fff", + fontWeight: 600, + fontSize: "0.9rem", + cursor: "pointer", +}; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { + const { error } = await searchParams; + const userCount = await prisma.user.count(); + const isBootstrap = userCount === 0; + + return ( +
+

{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}

+ + {isBootstrap && ( +

+ Henüz kullanıcı yok — panele erişecek ilk admin hesabını burada oluştur. +

+ )} + + {!isBootstrap && error && ( +

+ Kullanıcı adı veya şifre hatalı. +

+ )} + +
+ + + +
+
+ ); +} diff --git a/apps/frontend/lib/auth.ts b/apps/frontend/lib/auth.ts new file mode 100644 index 0000000..5a2aae3 --- /dev/null +++ b/apps/frontend/lib/auth.ts @@ -0,0 +1,40 @@ +import { cookies } from "next/headers"; +import { SignJWT, jwtVerify } from "jose"; + +export const SESSION_COOKIE_NAME = "session"; + +const secret = new TextEncoder().encode( + process.env.SESSION_SECRET ?? "dev-insecure-secret-change-me-in-production", +); + +export interface SessionPayload { + sub: string; + username: string; +} + +export async function createSessionToken(payload: SessionPayload): Promise { + return new SignJWT({ username: payload.username }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(payload.sub) + .setIssuedAt() + .setExpirationTime("30d") + .sign(secret); +} + +export async function verifySessionToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, secret); + if (typeof payload.sub !== "string" || typeof payload.username !== "string") return null; + return { sub: payload.sub, username: payload.username }; + } catch { + return null; + } +} + +/** Server Component helper — reads and verifies the session cookie. */ +export async function getSession(): Promise { + const jar = await cookies(); + const token = jar.get(SESSION_COOKIE_NAME)?.value; + if (!token) return null; + return verifySessionToken(token); +} diff --git a/apps/frontend/middleware.ts b/apps/frontend/middleware.ts new file mode 100644 index 0000000..0777d68 --- /dev/null +++ b/apps/frontend/middleware.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { SESSION_COOKIE_NAME, verifySessionToken } from "./lib/auth"; + +export async function middleware(req: NextRequest) { + if (req.nextUrl.pathname.startsWith("/login")) { + return NextResponse.next(); + } + + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await verifySessionToken(token) : null; + + if (!session) { + return NextResponse.redirect(new URL("/login", req.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 61afa21..26a345e 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -9,11 +9,14 @@ }, "dependencies": { "@streamclipper/db": "workspace:*", + "bcryptjs": "^2.4.3", + "jose": "^5.9.6", "next": "^15.1.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/docker-compose.yml b/docker-compose.yml index 5775651..1841e1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,7 @@ services: DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper API_DAEMON_URL: http://sc_api_daemon:4001 SHARED_MEDIA_ROOT: /shared-media + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} volumes: - shared-media:/shared-media ports: diff --git a/packages/db/prisma/migrations/20260901000000_users/migration.sql b/packages/db/prisma/migrations/20260901000000_users/migration.sql new file mode 100644 index 0000000..92bf2a3 --- /dev/null +++ b/packages/db/prisma/migrations/20260901000000_users/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7a631b2..e5de72c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -84,3 +84,12 @@ model AppSetting { @@map("app_settings") } + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String @map("password_hash") + createdAt DateTime @default(now()) @map("created_at") + + @@map("users") +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5114d6b..6e4cace 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: '@streamclipper/db': specifier: workspace:* version: link:../../packages/db + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + jose: + specifier: ^5.9.6 + version: 5.10.0 next: specifier: ^15.1.0 version: 15.5.24(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -54,6 +60,9 @@ importers: specifier: ^19.0.0 version: 19.2.8(react@19.2.8) devDependencies: + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/node': specifier: ^22.10.0 version: 22.20.1 @@ -533,6 +542,9 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -584,6 +596,9 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + body-parser@1.20.6: resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -834,6 +849,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -1404,6 +1422,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/bcryptjs@2.4.6': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -1469,6 +1489,8 @@ snapshots: array-flatten@1.1.1: {} + bcryptjs@2.4.3: {} + body-parser@1.20.6: dependencies: bytes: 3.1.2 @@ -1771,6 +1793,8 @@ snapshots: jiti@2.7.0: {} + jose@5.10.0: {} + luxon@3.7.2: {} math-intrinsics@1.1.0: {}