feat: panele auth sistemi ekle (kullanıcı/şifre + oturum + middleware)
Panel şu ana kadar herkese açıktı — URL'yi bilen herkes kanal ekleyip/silebilir, cookie güncelleyebilir, kayıt indirebilirdi. - users tablosu (bcrypt şifre hash'i) - /login: hiç kullanıcı yoksa "ilk admin hesabı oluştur" formu, varsa normal giriş formu - jose ile imzalanmış, httpOnly çerezde tutulan 30 günlük oturum - middleware.ts: /login hariç tüm rotaları korur (video stream route'u dahil — aynı origin istekleri çerezi otomatik taşır) - layout: oturum yoksa nav hiç gösterilmiyor, varsa kullanıcı adı + çıkış butonu ekleniyor SESSION_SECRET production'da zorunlu (docker-compose derleme zamanında kontrol ediyor); Coolify'a rastgele bir değer eklendi. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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=<gerçek veya kısa test yayını URL'si>` ile `apps/api-daemon`'ı
|
||||
|
||||
@@ -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 (
|
||||
<html lang="tr">
|
||||
<body>
|
||||
<nav className="nav">
|
||||
<Link href="/">Kanal Durumu</Link>
|
||||
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
||||
<Link href="/settings">Ayarlar</Link>
|
||||
</nav>
|
||||
{session && (
|
||||
<nav className="nav">
|
||||
<Link href="/">Kanal Durumu</Link>
|
||||
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
||||
<Link href="/settings">Ayarlar</Link>
|
||||
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<span className="mono">{session.username}</span>
|
||||
<form action={logout}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", font: "inherit" }}
|
||||
>
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</form>
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ maxWidth: "360px", margin: "4rem auto" }}>
|
||||
<h1>{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</h1>
|
||||
|
||||
{isBootstrap && (
|
||||
<p className="empty" style={{ marginBottom: "1rem" }}>
|
||||
Henüz kullanıcı yok — panele erişecek ilk admin hesabını burada oluştur.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isBootstrap && error && (
|
||||
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}>
|
||||
Kullanıcı adı veya şifre hatalı.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={isBootstrap ? bootstrapAdmin : login}
|
||||
className="card"
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
|
||||
>
|
||||
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Şifre"
|
||||
required
|
||||
minLength={isBootstrap ? 8 : undefined}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<button type="submit" style={buttonStyle}>
|
||||
{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string> {
|
||||
return new SignJWT({ username: payload.username })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(payload.sub)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("30d")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string): Promise<SessionPayload | null> {
|
||||
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<SessionPayload | null> {
|
||||
const jar = await cookies();
|
||||
const token = jar.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
return verifySessionToken(token);
|
||||
}
|
||||
@@ -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).*)"],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Generated
+24
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user