Initial WatchLink scaffold
This commit is contained in:
37
src/lib/access.ts
Normal file
37
src/lib/access.ts
Normal 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
93
src/lib/media.ts
Normal 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
13
src/lib/prisma.ts
Normal 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
38
src/lib/sample-data.ts
Normal 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
51
src/lib/session.ts
Normal 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
59
src/lib/user-actions.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user