Initial WatchLink scaffold
Some checks failed
Build / build (push) Failing after 1m29s
Release Dry Run / release-dry-run (push) Successful in 1m24s
Template Compliance / compliance (push) Failing after 5s

This commit is contained in:
MrSphay
2026-05-15 03:11:41 +02:00
commit d3e84feedd
51 changed files with 2215 additions and 0 deletions

37
src/lib/access.ts Normal file
View File

@@ -0,0 +1,37 @@
export type RoomVisibility = "PUBLIC" | "FRIENDS" | "ROLE_RESTRICTED" | "EXPLICIT";
type AccessInput = {
visibility: RoomVisibility;
isOwner?: boolean;
isAdmin?: boolean;
isFriend?: boolean;
hasRoomRole?: boolean;
explicitMember?: boolean;
};
export function canEnterRoom(input: AccessInput) {
if (input.isAdmin || input.isOwner) return true;
switch (input.visibility) {
case "PUBLIC":
return true;
case "FRIENDS":
return Boolean(input.isFriend || input.explicitMember);
case "ROLE_RESTRICTED":
return Boolean(input.hasRoomRole || input.explicitMember);
case "EXPLICIT":
return Boolean(input.explicitMember);
default:
return false;
}
}
export const SYSTEM_PERMISSIONS = [
"admin.users.manage",
"admin.roles.manage",
"admin.rooms.manage",
"rooms.create",
"rooms.manage.own",
"rooms.media.control",
"friends.manage"
] as const;

93
src/lib/media.ts Normal file
View File

@@ -0,0 +1,93 @@
import { z } from "zod";
export type NormalizedMedia = {
provider: "YOUTUBE" | "TWITCH" | "DIRECT" | "UNKNOWN";
originalUrl: string;
playbackUrl: string;
title?: string;
};
const urlSchema = z.string().url();
const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"]);
const TWITCH_HOSTS = new Set(["twitch.tv", "www.twitch.tv", "m.twitch.tv"]);
const DIRECT_VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov", ".m4v"];
export function normalizeMediaUrl(input: string): NormalizedMedia {
const originalUrl = input.trim();
const parsedInput = urlSchema.safeParse(originalUrl);
if (!parsedInput.success) {
return {
provider: "UNKNOWN",
originalUrl,
playbackUrl: originalUrl
};
}
const url = new URL(parsedInput.data);
const host = url.hostname.toLowerCase();
if (YOUTUBE_HOSTS.has(host)) {
const id = getYoutubeId(url);
if (id) {
return {
provider: "YOUTUBE",
originalUrl,
playbackUrl: `https://www.youtube.com/embed/${id}?enablejsapi=1&origin=${encodeURIComponent(
process.env.NEXTAUTH_URL || "http://localhost:3000"
)}`
};
}
}
if (TWITCH_HOSTS.has(host)) {
const parts = url.pathname.split("/").filter(Boolean);
const parent = new URL(process.env.NEXTAUTH_URL || "http://localhost:3000").hostname;
if (parts[0] === "videos" && parts[1]) {
return {
provider: "TWITCH",
originalUrl,
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=${parent}`
};
}
if (parts[0]) {
return {
provider: "TWITCH",
originalUrl,
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=${parent}`
};
}
}
if (DIRECT_VIDEO_EXTENSIONS.some((extension) => url.pathname.toLowerCase().endsWith(extension))) {
return {
provider: "DIRECT",
originalUrl,
playbackUrl: originalUrl
};
}
return {
provider: "UNKNOWN",
originalUrl,
playbackUrl: originalUrl
};
}
function getYoutubeId(url: URL) {
if (url.hostname === "youtu.be") {
return url.pathname.split("/").filter(Boolean)[0];
}
if (url.pathname === "/watch") {
return url.searchParams.get("v");
}
const parts = url.pathname.split("/").filter(Boolean);
if (["embed", "shorts", "live"].includes(parts[0])) {
return parts[1];
}
return null;
}

13
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

38
src/lib/sample-data.ts Normal file
View File

@@ -0,0 +1,38 @@
export const dashboardStats = [
{ label: "Online", value: "12", tone: "good" },
{ label: "Active rooms", value: "5", tone: "info" },
{ label: "Pending friends", value: "3", tone: "warn" },
{ label: "Queue items", value: "18", tone: "neutral" }
];
export const rooms = [
{ name: "@maria", owner: "Maria", visibility: "Friends", status: "Live", source: "YouTube" },
{ name: "@admin", owner: "Admin", visibility: "Role", status: "Idle", source: "Twitch" },
{ name: "Friday Ops", owner: "Ops", visibility: "Public", status: "Live", source: "Direct" }
];
export const friends = [
{ name: "Maria", state: "Online", room: "@maria" },
{ name: "Jens", state: "Away", room: "@jens" },
{ name: "Aylin", state: "Offline", room: "@aylin" }
];
export const queue = [
{ title: "Build stream recap", provider: "YouTube", by: "Maria", duration: "12:40" },
{ title: "Dockge deployment notes", provider: "Twitch", by: "Admin", duration: "Live" },
{ title: "Local media sample", provider: "Direct", by: "Jens", duration: "03:20" }
];
export const participants = [
{ name: "Admin", role: "Admin", status: "Host" },
{ name: "Maria", role: "Member", status: "Synced" },
{ name: "Jens", role: "Member", status: "Synced" },
{ name: "Aylin", role: "Guest", status: "Buffering" }
];
export const activity = [
"Maria set a YouTube source",
"Admin seeked to 01:22",
"Jens joined @admin",
"Aylin requested friendship"
];

51
src/lib/session.ts Normal file
View File

@@ -0,0 +1,51 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "./prisma";
const COOKIE_NAME = "watchlink_session";
function secret() {
return process.env.NEXTAUTH_SECRET || "development-only-change-me";
}
function sign(value: string) {
return createHmac("sha256", secret()).update(value).digest("base64url");
}
export async function setSession(userId: string) {
const cookieStore = await cookies();
const value = `${userId}.${sign(userId)}`;
cookieStore.set(COOKIE_NAME, value, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 30
});
}
export async function clearSession() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
}
export async function getCurrentUser() {
const cookieStore = await cookies();
const raw = cookieStore.get(COOKIE_NAME)?.value;
if (!raw) return null;
const [userId, signature] = raw.split(".");
if (!userId || !signature) return null;
const expected = sign(userId);
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(signature);
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
return null;
}
return prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } }
});
}

59
src/lib/user-actions.ts Normal file
View File

@@ -0,0 +1,59 @@
"use server";
import { compare, hash } from "bcryptjs";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { setSession } from "./session";
function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "");
}
export async function registerUser(formData: FormData) {
const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || "");
if (!username || password.length < 10) {
throw new Error("Username is required and password must be at least 10 characters.");
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
data: {
username,
displayName: username,
passwordHash,
ownedRooms: {
create: {
slug: `@${username}`,
name: `${username}'s room`,
visibility: "FRIENDS"
}
}
}
});
await setSession(user.id);
redirect("/dashboard");
}
export async function loginUser(formData: FormData) {
const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || "");
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
throw new Error("Invalid username or password.");
}
const ok = await compare(password, user.passwordHash);
if (!ok) {
throw new Error("Invalid username or password.");
}
await setSession(user.id);
redirect("/dashboard");
}