commit d3e84feeddc6b483031b04819b1336bcbdacef07 Author: MrSphay Date: Fri May 15 03:11:41 2026 +0200 Initial WatchLink scaffold diff --git a/.codex/project.md b/.codex/project.md new file mode 100644 index 0000000..d786906 --- /dev/null +++ b/.codex/project.md @@ -0,0 +1,69 @@ +# Codex Project Notes + +## Project + +`WatchLink` is a Dockerized Next.js + Postgres web app for persistent shared watch rooms with accounts, friends, roles, permissions, admin setup, and realtime playback sync. + +Repository: + +```text +MrSphay/WatchLink +``` + +## Commands + +```text +Install: npm install +Dev: npm run dev +Lint: npm run lint +Typecheck: npm run typecheck +Test: npm run test +Build: npm run build +Audit: npm run audit +Release check: npm run release:check +Docker: docker compose up --build +``` + +## Stack + +```text +Next.js App Router, React, TypeScript, Prisma, Postgres, Socket.IO, Docker +``` + +Package manager: + +```text +npm +``` + +## Build Artifacts + +Next.js standalone build output: + +```text +.next/standalone +``` + +Docker image: + +```text +git.wilkensxl.de/MrSphay/watchlink:latest +``` + +## Security Rules + +- Do not commit secrets, `.env` files, tokens, private keys, or certificates. +- Use `.env.example` for documentation only. +- Review `docs/security-review.md` before release work. +- Keep package publishing secrets in Gitea secrets as `REGISTRY_TOKEN`. + +## Release Rules + +Before a release: + +1. run `npm run release:check`, +2. verify Docker build, +3. verify Gitea Actions are green, +4. verify the pushed container image can be pulled, +5. update README and changelog, +6. create tags/releases only when explicitly requested. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c004bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.kit +.env +.env.* +!.env.example +coverage +*.log +docs/agent-handoff.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e067ea --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL="postgresql://watchlink:watchlink@localhost:5432/watchlink?schema=public" +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="change-me-with-openssl-rand-base64-32" +POSTGRES_DB="watchlink" +POSTGRES_USER="watchlink" +POSTGRES_PASSWORD="watchlink" +PORT="3000" diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..b3442f7 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,43 @@ +name: Build + +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + IMAGE_NAME: git.wilkensxl.de/MrSphay/watchlink:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build + + - name: Build Docker image + run: docker build -t "$IMAGE_NAME" . + + - name: Publish Docker image + if: ${{ secrets.REGISTRY_TOKEN != '' }} + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.wilkensxl.de -u "${{ gitea.actor }}" --password-stdin + docker push "$IMAGE_NAME" diff --git a/.gitea/workflows/dependency-check.yml b/.gitea/workflows/dependency-check.yml new file mode 100644 index 0000000..da2efb1 --- /dev/null +++ b/.gitea/workflows/dependency-check.yml @@ -0,0 +1,27 @@ +name: Dependency Check + +on: + schedule: + - cron: "43 5 * * 3" + workflow_dispatch: + +jobs: + dependencies: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Audit + run: npm audit --omit=dev --audit-level=high + + - name: Outdated report + run: npm outdated || true diff --git a/.gitea/workflows/release-dry-run.yml b/.gitea/workflows/release-dry-run.yml new file mode 100644 index 0000000..1c9e6bf --- /dev/null +++ b/.gitea/workflows/release-dry-run.yml @@ -0,0 +1,33 @@ +name: Release Dry Run + +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + release-dry-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check release docs + run: test -f docs/release-checklist.md && test -f docs/security-review.md && test -f CHANGELOG.md + + - name: Check unresolved placeholders + run: | + ! grep -RInE "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND" -- . --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.next + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Release check + run: npm run release:check diff --git a/.gitea/workflows/repo-cleanup.yml b/.gitea/workflows/repo-cleanup.yml new file mode 100644 index 0000000..817bd2d --- /dev/null +++ b/.gitea/workflows/repo-cleanup.yml @@ -0,0 +1,21 @@ +name: Repository Cleanup Report + +on: + schedule: + - cron: "29 4 * * 2" + workflow_dispatch: + +jobs: + report: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Report generated files + run: | + find . -maxdepth 3 \( -path "./node_modules" -o -path "./.next" -o -path "./coverage" -o -path "./dist" \) -print + + - name: Report large files + run: | + find . -type f -size +10M -not -path "./.git/*" -print diff --git a/.gitea/workflows/security-scan.yml b/.gitea/workflows/security-scan.yml new file mode 100644 index 0000000..b191a42 --- /dev/null +++ b/.gitea/workflows/security-scan.yml @@ -0,0 +1,32 @@ +name: Security Scan + +on: + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Audit dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Scan secret-prone files + run: | + ! find . -type f \( -name ".env" -o -name "*.pem" -o -name "*.key" \) -not -path "./node_modules/*" | grep . + + - name: Scan instruction-injection markers + run: | + ! grep -RInE "ignore previous instructions|system prompt|developer message" -- . --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.next diff --git a/.gitea/workflows/template-compliance.yml b/.gitea/workflows/template-compliance.yml new file mode 100644 index 0000000..bbf258f --- /dev/null +++ b/.gitea/workflows/template-compliance.yml @@ -0,0 +1,29 @@ +name: Template Compliance + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + compliance: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Required files + run: | + test -f AGENTS.md + test -f .codex/project.md + test -f README.md + test -f SECURITY.md + test -f CHANGELOG.md + test -f .gitignore + + - name: Placeholder scan + run: | + ! grep -RInE "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|PACKAGE_NAME|ARTIFACT_NAME|ARTIFACT_OUTPUT_DIRECTORY" -- AGENTS.md .codex README.md docs .gitea || exit 1 + + - name: README divider + run: grep -q "rainbow.png" README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbed833 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +.next/ +dist/ +build/ +coverage/ +*.log +.env +.env.* +!.env.example +docker-compose.override.yml +.DS_Store +Thumbs.db +.kit/ +package-registry/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ec6111 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Agent Instructions + +## Project + +WatchLink is a Dockerized Next.js + Postgres web app for persistent shared watch rooms. It supports local accounts, friend relationships, role and permission management, first-run admin setup, and synchronized playback for YouTube, Twitch, and direct video URLs. + +## Repository Rules + +- Use `codex-agent-repository-kit` conventions. This repository was initialized from `https://git.wilkensxl.de/MrSphay/codex-agent-repository-kit.git`. +- At the start of every task, check `git status --short --branch`. If an upstream remote exists and the working tree is clean, use a safe fast-forward pull. +- Preserve unrelated user changes. Do not rewrite history or run destructive git commands unless explicitly requested. +- Do not commit secrets, `.env` files, private keys, certificates, or tokens. +- Keep `.codex/project.md` aligned when commands, artifact paths, or release rules change. +- Do not create a release unless explicitly requested. +- Gitea target: `git.wilkensxl.de/MrSphay/WatchLink`. +- Docker image target: `git.wilkensxl.de/MrSphay/watchlink:latest`. + +## Commands + +```bash +npm install +npm run dev +npm run typecheck +npm run test +npm run build +npm run audit +npm run release:check +docker compose up --build +``` + +## Architecture Notes + +- Next.js App Router lives under `src/app`. +- Shared UI components live under `src/components`. +- Domain helpers live under `src/lib`. +- Prisma schema lives in `prisma/schema.prisma`. +- The custom `server.js` hosts Next.js and Socket.IO at `/api/socket`. +- System theme is handled by CSS variables and `prefers-color-scheme`. + +## Security Notes + +- `NEXTAUTH_SECRET` must be changed in production. +- `DATABASE_URL` must not be committed outside `.env.example`. +- Passwords are hashed with bcrypt. +- Sessions are signed HTTP-only cookies. +- CI publishing uses `REGISTRY_TOKEN`; never commit it. + +## Finish Checklist + +- `git diff --check` passes. +- Run the cheapest reliable verification command available in the environment. +- If a pushed Gitea workflow starts, poll it until success or report a concrete blocker. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9c6b80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial WatchLink scaffold with Next.js, Prisma, Socket.IO, Docker, and Gitea repository baseline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b27d534 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +## Local Workflow + +1. Install dependencies with `npm install`. +2. Copy `.env.example` to `.env`. +3. Run `npm run db:push` for local development databases. +4. Run `npm run typecheck`, `npm run test`, and `npm run build` before opening a pull request. + +## Rules + +- Keep changes scoped. +- Do not commit generated dependency folders or local environment files. +- Update README and `.codex/project.md` when commands or deployment behavior changes. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..06fb068 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json ./ +RUN npm install + +FROM node:22-bookworm-slim AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate && npm run build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/server.js ./server.js +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..02e02b3 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# WatchLink + +WatchLink is a self-hosted shared-watch web app with persistent user rooms, local accounts, friends, role-based administration, and realtime playback state. + +

-----------------------------------------------------

+ +## Features + +- First-run setup creates the first admin user. +- Username/password accounts with bcrypt password hashing. +- Every user gets a stable personal room like `/rooms/@username`. +- Friend requests and room access are modeled for friends-only, public, role-restricted, and explicit access. +- Admin surface for rooms, users, roles, and permissions. +- Shared playback state via Socket.IO. +- Media URL normalization for YouTube, Twitch, and direct video URLs. +- Uptime Kuma/Dockge-inspired app shell with system light/dark theme. +- Docker Compose stack for app and Postgres. + +

-----------------------------------------------------

+ +## Development + +```bash +npm install +cp .env.example .env +npm run db:push +npm run dev +``` + +Open `http://localhost:3000/setup` on a clean database to create the first admin. + +Useful commands: + +```bash +npm run typecheck +npm run test +npm run build +npm run audit +``` + +

-----------------------------------------------------

+ +## Docker + +```bash +docker compose up --build +``` + +The Compose stack exposes the web app on `http://localhost:3000` and uses a Postgres volume named `watchlink_postgres-data`. + +Build and publish the Gitea image: + +```bash +docker build -t git.wilkensxl.de/MrSphay/watchlink:latest . +docker login git.wilkensxl.de +docker push git.wilkensxl.de/MrSphay/watchlink:latest +``` + +

-----------------------------------------------------

+ +## Gitea + +Target repository: + +```text +git@git.wilkensxl.de:MrSphay/WatchLink.git +``` + +Target container image: + +```text +git.wilkensxl.de/MrSphay/watchlink:latest +``` + +CI expects `REGISTRY_TOKEN` to be configured as a repository or organization secret when image publishing should run. + +

-----------------------------------------------------

+ +## Project Info + +- Stack: Next.js, React, TypeScript, Prisma, Postgres, Socket.IO, Docker +- Repository baseline: `codex-agent-repository-kit` +- License: not declared yet diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2d136de --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Supported Version + +WatchLink is pre-release. Security fixes apply to the current `main` branch. + +## Reporting + +Report vulnerabilities privately to the repository owner. Do not open public issues for secrets, authentication bypasses, or data exposure. + +## Baseline Rules + +- Do not commit `.env`, tokens, private keys, certificates, or database dumps. +- Change `NEXTAUTH_SECRET` before production use. +- Use a strong Postgres password in production. +- Store Gitea registry credentials in repository or organization secrets. +- Review `docs/security-review.md` before release work. diff --git a/blueprint.json b/blueprint.json new file mode 100644 index 0000000..876ffc7 --- /dev/null +++ b/blueprint.json @@ -0,0 +1,9 @@ +{ + "variables": { + "name": "WatchLink", + "description": "Persistent shared watch rooms with accounts, friends, roles, and admin controls." + }, + "templates": { + "section-line": "

\"-----------------------------------------------------\"

" + } +} diff --git a/blueprint.md b/blueprint.md new file mode 100644 index 0000000..d01f211 --- /dev/null +++ b/blueprint.md @@ -0,0 +1,36 @@ +# WatchLink + +WatchLink is a self-hosted shared-watch web app with persistent user rooms, local accounts, friends, role-based administration, and realtime playback state. + +{{ template:section-line }} + +## Features + +- First-run admin setup +- Username/password accounts +- Persistent user rooms +- Friend graph and access modes +- Shared playback state for YouTube, Twitch, and direct video URLs +- Uptime Kuma/Dockge-inspired app shell +- Dockerized Next.js and Postgres stack + +{{ template:section-line }} + +## Development + +```bash +npm install +cp .env.example .env +npm run db:push +npm run dev +``` + +{{ template:section-line }} + +## Docker + +```bash +docker compose up --build +``` + +Target image: `git.wilkensxl.de/MrSphay/watchlink:latest` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fdf4f93 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-watchlink} + POSTGRES_USER: ${POSTGRES_USER:-watchlink} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-watchlink} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-watchlink} -d ${POSTGRES_DB:-watchlink}"] + interval: 10s + timeout: 5s + retries: 5 + + web: + build: . + image: git.wilkensxl.de/MrSphay/watchlink:latest + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-watchlink}:${POSTGRES_PASSWORD:-watchlink}@postgres:5432/${POSTGRES_DB:-watchlink}?schema=public + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-me-with-openssl-rand-base64-32} + PORT: 3000 + ports: + - "3000:3000" + command: sh -c "npx prisma migrate deploy && node server.js" + +volumes: + postgres-data: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md new file mode 100644 index 0000000..ab9f41a --- /dev/null +++ b/docs/agent-handoff.md @@ -0,0 +1,11 @@ +# Agent Handoff + +## Current State + +Initial implementation created from `codex-agent-repository-kit` guidance. + +## Notes + +- The kit was read and applied as a source template, not kept as a project dependency. +- npm and Docker were not available in the initial local shell, so dependency installation and Docker verification may need a machine with those tools in PATH. +- `GITEA_TOKEN` may be available locally, but it must not be printed or committed. diff --git a/docs/design/watchlink-dashboard-concept.png b/docs/design/watchlink-dashboard-concept.png new file mode 100644 index 0000000..9e81906 Binary files /dev/null and b/docs/design/watchlink-dashboard-concept.png differ diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..9937edc --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,9 @@ +# Release Checklist + +- [ ] `npm run release:check` passes. +- [ ] `docker compose up --build` starts app and Postgres. +- [ ] First setup works on a clean database. +- [ ] Gitea Actions are green. +- [ ] Container image `git.wilkensxl.de/MrSphay/watchlink:latest` is pushed and pull-tested. +- [ ] README and CHANGELOG are current. +- [ ] No `.env`, tokens, private keys, or local secrets are tracked. diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 0000000..8289947 --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,5 @@ +# Release Notes + +## 0.1.0 Draft + +Initial pre-release of WatchLink with persistent rooms, account setup, dashboard UI, Prisma schema, Socket.IO realtime server, and Docker packaging. diff --git a/docs/security-review.md b/docs/security-review.md new file mode 100644 index 0000000..62a6e80 --- /dev/null +++ b/docs/security-review.md @@ -0,0 +1,17 @@ +# Security Review + +## Scope + +WatchLink handles user accounts, password hashes, friendship data, room access rules, media URLs, and realtime playback events. + +## Current Controls + +- Passwords are hashed with bcrypt. +- Sessions use HTTP-only signed cookies. +- Prisma models enforce uniqueness for users, friendships, and room slugs. +- `.env` files are ignored except `.env.example`. +- Container publishing expects Gitea `REGISTRY_TOKEN` as a secret. + +## Release Review Notes + +Fill this section during release readiness work with commands run, CI links, audit results, and any accepted risks. diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..886135f --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// This file is generated by Next.js in normal installs. It is checked in here +// so a fresh clone has correct TypeScript references before the first build. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..6d835e2 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,11 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + experimental: { + serverActions: { + allowedOrigins: ["localhost:3000"] + } + } +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..86d920e --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "watchlink", + "version": "0.1.0", + "private": true, + "description": "Persistent shared watch rooms with accounts, friends, roles, and admin controls.", + "scripts": { + "dev": "node server.js", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "build": "prisma generate && next build", + "start": "NODE_ENV=production node server.js", + "audit": "npm audit --omit=dev --audit-level=high", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate deploy", + "db:push": "prisma db push", + "release:check": "npm run typecheck && npm run test && npm run build" + }, + "dependencies": { + "@auth/prisma-adapter": "^2.7.4", + "@prisma/client": "^6.1.0", + "bcryptjs": "^2.4.3", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "next": "^15.1.0", + "next-auth": "^5.0.0-beta.25", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "eslint": "^9.17.0", + "eslint-config-next": "^15.1.0", + "prisma": "^6.1.0", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..1de92a8 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,134 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum RoleScope { + SYSTEM + ROOM +} + +enum FriendStatus { + PENDING + ACCEPTED + DECLINED + BLOCKED +} + +enum RoomVisibility { + PUBLIC + FRIENDS + ROLE_RESTRICTED + EXPLICIT +} + +enum MediaProvider { + YOUTUBE + TWITCH + DIRECT + UNKNOWN +} + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + displayName String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + roles UserRole[] + ownedRooms Room[] @relation("RoomOwner") + sentFriends Friendship[] @relation("FriendRequester") + gotFriends Friendship[] @relation("FriendReceiver") + roomMembers RoomMember[] + submitted MediaSource[] +} + +model Role { + id String @id @default(cuid()) + name String @unique + description String? + scope RoleScope @default(SYSTEM) + createdAt DateTime @default(now()) + users UserRole[] + permissions RolePermission[] +} + +model Permission { + id String @id @default(cuid()) + key String @unique + description String? + roles RolePermission[] +} + +model UserRole { + userId String + roleId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@id([userId, roleId]) +} + +model RolePermission { + roleId String + permissionId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([roleId, permissionId]) +} + +model Friendship { + id String @id @default(cuid()) + requesterId String + receiverId String + status FriendStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requester User @relation("FriendRequester", fields: [requesterId], references: [id], onDelete: Cascade) + receiver User @relation("FriendReceiver", fields: [receiverId], references: [id], onDelete: Cascade) + + @@unique([requesterId, receiverId]) +} + +model Room { + id String @id @default(cuid()) + slug String @unique + name String + ownerId String? + visibility RoomVisibility @default(FRIENDS) + currentState Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + owner User? @relation("RoomOwner", fields: [ownerId], references: [id], onDelete: SetNull) + members RoomMember[] + mediaSources MediaSource[] +} + +model RoomMember { + roomId String + userId String + canManage Boolean @default(false) + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([roomId, userId]) +} + +model MediaSource { + id String @id @default(cuid()) + roomId String + submitterId String? + provider MediaProvider + originalUrl String + playbackUrl String + title String? + createdAt DateTime @default(now()) + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull) +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..0ea89e3 --- /dev/null +++ b/server.js @@ -0,0 +1,70 @@ +const { createServer } = require("node:http"); +const next = require("next"); +const { Server } = require("socket.io"); + +const dev = process.env.NODE_ENV !== "production"; +const hostname = process.env.HOSTNAME || "0.0.0.0"; +const port = Number(process.env.PORT || 3000); + +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +const roomStates = new Map(); + +app.prepare().then(() => { + const httpServer = createServer((req, res) => handle(req, res)); + const io = new Server(httpServer, { + path: "/api/socket", + cors: { + origin: process.env.NEXTAUTH_URL || "http://localhost:3000" + } + }); + + io.on("connection", (socket) => { + socket.on("room:join", ({ roomSlug, user }) => { + if (!roomSlug) return; + socket.join(roomSlug); + socket.data.roomSlug = roomSlug; + socket.data.user = user || "Guest"; + socket.emit("room:state", roomStates.get(roomSlug) || null); + socket.to(roomSlug).emit("presence:join", { user: socket.data.user }); + }); + + socket.on("media:set", (payload) => updateRoom(socket, "media:set", payload)); + socket.on("playback:play", (payload) => updateRoom(socket, "playback:play", payload)); + socket.on("playback:pause", (payload) => updateRoom(socket, "playback:pause", payload)); + socket.on("playback:seek", (payload) => updateRoom(socket, "playback:seek", payload)); + socket.on("chat:message", (payload) => relayRoom(socket, "chat:message", payload)); + + socket.on("disconnect", () => { + if (socket.data.roomSlug) { + socket.to(socket.data.roomSlug).emit("presence:leave", { user: socket.data.user || "Guest" }); + } + }); + }); + + function relayRoom(socket, event, payload) { + const roomSlug = socket.data.roomSlug; + if (!roomSlug) return; + io.to(roomSlug).emit(event, { ...payload, user: socket.data.user || "Guest", at: Date.now() }); + } + + function updateRoom(socket, event, payload) { + const roomSlug = socket.data.roomSlug; + if (!roomSlug) return; + const previous = roomStates.get(roomSlug) || {}; + const nextState = { + ...previous, + ...payload, + lastEvent: event, + updatedBy: socket.data.user || "Guest", + updatedAt: Date.now() + }; + roomStates.set(roomSlug, nextState); + io.to(roomSlug).emit(event, nextState); + } + + httpServer.listen(port, hostname, () => { + console.log(`WatchLink ready on http://${hostname}:${port}`); + }); +}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..1322519 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,65 @@ +import { AppShell } from "@/components/app-shell"; +import { StatusBadge } from "@/components/status-badge"; +import { SYSTEM_PERMISSIONS } from "@/lib/access"; +import { rooms } from "@/lib/sample-data"; + +export default function AdminPage() { + return ( + +
+
+

Admin

+

Manage roles, rooms, permissions, and users.

+
+ Admin +
+
+
+
+

Rooms

+ +
+
+ + + + + + + + + + + {rooms.map((room) => ( + + + + + + + ))} + +
NameOwnerAccessStatus
{room.name}{room.owner}{room.visibility}{room.status}
+
+
+
+
+

Permissions

+ Roles +
+
+ {SYSTEM_PERMISSIONS.map((permission) => ( +
+
+ {permission} + Assignable to roles +
+ Enabled +
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..afbd92f --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,85 @@ +import { AppShell } from "@/components/app-shell"; +import { RoomConsole } from "@/components/room-console"; +import { StatusBadge } from "@/components/status-badge"; +import { getCurrentUser } from "@/lib/session"; +import { dashboardStats, friends, rooms } from "@/lib/sample-data"; + +export default async function DashboardPage() { + const user = await getCurrentUser(); + + return ( + +
+
+

Dashboard

+

{user ? `Signed in as ${user.username}` : "Persistent rooms, friends, and shared playback state."}

+
+
+ Online + System theme +
+
+ +
+ {dashboardStats.map((stat) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+ + + +
+
+
+

Rooms

+ Persistent +
+
+ + + + + + + + + + + + {rooms.map((room) => ( + + + + + + + + ))} + +
NameOwnerAccessStatusSource
{room.name}{room.owner}{room.visibility}{room.status}{room.source}
+
+
+
+
+

Friends

+ 3 linked +
+
+ {friends.map((friend) => ( +
+
+ {friend.name} + {friend.room} +
+ {friend.state} +
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/friends/page.tsx b/src/app/friends/page.tsx new file mode 100644 index 0000000..1ec8544 --- /dev/null +++ b/src/app/friends/page.tsx @@ -0,0 +1,37 @@ +import { AppShell } from "@/components/app-shell"; +import { StatusBadge } from "@/components/status-badge"; +import { friends } from "@/lib/sample-data"; + +export default function FriendsPage() { + return ( + +
+
+

Friends

+

Add users, accept requests, and enter persistent rooms.

+
+ +
+
+
+

Friend graph

+ Username search +
+
+ {friends.map((friend) => ( +
+
+ {friend.name} + {friend.room} +
+
+ {friend.state} + +
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..d03e12a --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,402 @@ +:root { + color-scheme: light dark; + --bg: #f5f7fb; + --panel: #ffffff; + --panel-2: #f9fafb; + --text: #18212f; + --muted: #687487; + --border: #d7dde7; + --accent: #16a34a; + --accent-2: #0891b2; + --warn: #d97706; + --danger: #dc2626; + --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f141b; + --panel: #151b23; + --panel-2: #10161d; + --text: #e5edf7; + --muted: #8b97a8; + --border: #263241; + --accent: #22c55e; + --accent-2: #06b6d4; + --warn: #f59e0b; + --danger: #f87171; + --shadow: 0 18px 45px rgba(0, 0, 0, 0.24); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select { + font: inherit; +} + +.app-shell { + display: grid; + min-height: 100vh; + grid-template-columns: 248px 1fr; +} + +.sidebar { + border-right: 1px solid var(--border); + background: var(--panel); + padding: 20px 14px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + margin: 2px 8px 24px; + font-weight: 750; + font-size: 20px; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} + +.nav-list { + display: grid; + gap: 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + border-radius: 8px; + padding: 10px 12px; + color: var(--muted); + font-size: 14px; + font-weight: 650; +} + +.nav-item.active, +.nav-item:hover { + background: var(--panel-2); + color: var(--text); +} + +.main { + min-width: 0; + padding: 18px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.title-block h1 { + margin: 0; + font-size: 24px; + line-height: 1.2; +} + +.title-block p { + margin: 4px 0 0; + color: var(--muted); + font-size: 14px; +} + +.status-row, +.stats-grid { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 18px; +} + +.stat, +.panel, +.auth-card { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.stat { + padding: 14px; +} + +.stat span, +.eyebrow { + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.stat strong { + display: block; + margin-top: 6px; + font-size: 26px; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 5px 9px; + background: var(--panel); + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.badge.good { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + color: var(--accent); +} + +.badge.warn { + border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); + color: var(--warn); +} + +.room-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 18px; +} + +.panel { + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + padding: 12px 14px; +} + +.panel-header h2, +.panel-header h3 { + margin: 0; + font-size: 15px; +} + +.panel-body { + padding: 14px; +} + +.video-frame { + display: grid; + min-height: 430px; + place-items: center; + background: + linear-gradient(135deg, rgba(34, 197, 94, 0.08), transparent 36%), + #05070a; +} + +.video-state { + width: min(520px, 86%); + border: 1px solid #1f2937; + border-radius: 8px; + padding: 22px; + background: rgba(15, 23, 42, 0.82); + color: #e5edf7; +} + +.video-state h2 { + margin: 0 0 10px; + font-size: 24px; +} + +.controls { + display: grid; + grid-template-columns: auto auto 1fr auto; + gap: 10px; + border-top: 1px solid var(--border); + padding: 12px; +} + +.button, +.icon-button { + display: inline-flex; + min-height: 38px; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); + color: var(--text); + padding: 8px 12px; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.button.primary { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--accent) 18%, var(--panel)); + color: var(--accent); +} + +.input { + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + color: var(--text); + padding: 8px 10px; +} + +.stack { + display: grid; + gap: 12px; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border); + padding: 10px 0; +} + +.row:last-child { + border-bottom: 0; +} + +.row-title { + display: grid; + gap: 3px; +} + +.row-title strong { + font-size: 14px; +} + +.row-title span { + color: var(--muted); + font-size: 12px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.table th, +.table td { + border-bottom: 1px solid var(--border); + padding: 10px; + text-align: left; +} + +.table th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.auth-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 24px; +} + +.auth-card { + width: min(440px, 100%); + padding: 22px; +} + +.form { + display: grid; + gap: 12px; +} + +.form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +@media (max-width: 1100px) { + .room-layout { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + } + + .nav-list { + display: flex; + overflow-x: auto; + } + + .nav-item { + white-space: nowrap; + } + + .stats-grid, + .controls { + grid-template-columns: 1fr; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..073cf04 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "WatchLink", + description: "Persistent shared watch rooms for friends and teams." +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..49f9865 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import { loginUser } from "@/lib/user-actions"; + +export default function LoginPage() { + return ( +
+
+
+

Login

+

Enter WatchLink with your username and password.

+
+
+ + + + + Create account + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..9ef1235 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/dashboard"); +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..bd8ede5 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import { registerUser } from "@/lib/user-actions"; + +export default function RegisterPage() { + return ( +
+
+
+

Create account

+

Register a username and get a persistent room.

+
+
+ + + + + Login instead + +
+
+
+ ); +} diff --git a/src/app/rooms/[slug]/page.tsx b/src/app/rooms/[slug]/page.tsx new file mode 100644 index 0000000..9b9d5f9 --- /dev/null +++ b/src/app/rooms/[slug]/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell"; +import { RoomConsole } from "@/components/room-console"; +import { StatusBadge } from "@/components/status-badge"; + +export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const roomSlug = decodeURIComponent(slug); + + return ( + +
+
+

{roomSlug}

+

Stable room address with shared playback for authorized users.

+
+
+ Online + All participants may control +
+
+ +
+ ); +} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..1141571 --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,123 @@ +import { redirect } from "next/navigation"; +import { hash } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { SYSTEM_PERMISSIONS } from "@/lib/access"; +import { setSession } from "@/lib/session"; + +async function hasAdmin() { + try { + const admin = await prisma.userRole.findFirst({ + where: { role: { name: "admin" } }, + select: { userId: true } + }); + return Boolean(admin); + } catch { + return false; + } +} + +async function createFirstAdmin(formData: FormData) { + "use server"; + + const username = String(formData.get("username") || "").trim().toLowerCase(); + 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 existingAdmin = await prisma.userRole.findFirst({ + where: { role: { name: "admin" } }, + select: { userId: true } + }); + + if (existingAdmin) { + redirect("/login"); + } + + const passwordHash = await hash(password, 12); + + const user = await prisma.$transaction(async (tx) => { + const permissions = await Promise.all( + SYSTEM_PERMISSIONS.map((key) => + tx.permission.upsert({ + where: { key }, + update: {}, + create: { key, description: key } + }) + ) + ); + + const adminRole = await tx.role.upsert({ + where: { name: "admin" }, + update: {}, + create: { + name: "admin", + description: "Full system administrator" + } + }); + + await Promise.all( + permissions.map((permission) => + tx.rolePermission.upsert({ + where: { roleId_permissionId: { roleId: adminRole.id, permissionId: permission.id } }, + update: {}, + create: { roleId: adminRole.id, permissionId: permission.id } + }) + ) + ); + + const createdUser = await tx.user.create({ + data: { + username, + displayName: username, + passwordHash + } + }); + + await tx.userRole.create({ data: { userId: createdUser.id, roleId: adminRole.id } }); + await tx.room.create({ + data: { + slug: `@${username}`, + name: `${username}'s room`, + ownerId: createdUser.id, + visibility: "FRIENDS" + } + }); + + return createdUser; + }); + + await setSession(user.id); + redirect("/dashboard"); +} + +export default async function SetupPage() { + if (await hasAdmin()) { + redirect("/login"); + } + + return ( +
+
+
+

WatchLink first setup

+

Create the first admin account. This screen locks after setup.

+
+
+ + + +
+
+
+ ); +} diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx new file mode 100644 index 0000000..fba6213 --- /dev/null +++ b/src/components/app-shell.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { Gauge, MonitorPlay, Shield, UserRoundPlus, UsersRound } from "lucide-react"; + +const nav = [ + { href: "/dashboard", label: "Dashboard", icon: Gauge }, + { href: "/rooms/@admin", label: "Rooms", icon: MonitorPlay }, + { href: "/friends", label: "Friends", icon: UsersRound }, + { href: "/admin", label: "Admin", icon: Shield }, + { href: "/setup", label: "Setup", icon: UserRoundPlus } +]; + +export function AppShell({ children, active = "Dashboard" }: { children: React.ReactNode; active?: string }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx new file mode 100644 index 0000000..9f9f596 --- /dev/null +++ b/src/components/room-console.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Pause, Play, Radio, SkipForward } from "lucide-react"; +import { io } from "socket.io-client"; +import { normalizeMediaUrl } from "@/lib/media"; +import { activity, participants, queue } from "@/lib/sample-data"; +import { StatusBadge } from "./status-badge"; + +const socket = io({ + path: "/api/socket", + autoConnect: false +}); + +export function RoomConsole({ roomSlug = "@admin" }: { roomSlug?: string }) { + const [connected, setConnected] = useState(false); + const [source, setSource] = useState("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + const media = useMemo(() => normalizeMediaUrl(source), [source]); + + function connect() { + if (!socket.connected) { + socket.connect(); + socket.emit("room:join", { roomSlug, user: "Admin" }); + } + setConnected(true); + } + + function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") { + connect(); + socket.emit(event, { + provider: media.provider, + originalUrl: media.originalUrl, + playbackUrl: media.playbackUrl, + position: event === "playback:seek" ? 82 : undefined + }); + } + + return ( +
+
+
+
+

{roomSlug}

+ Persistent room +
+ {connected ? "Online" : "Local preview"} +
+
+
+ {media.provider} +

Shared playback state

+

+ Participants in this room can set the source, play, pause, and seek. Late joiners receive the latest room + state from the realtime server. +

+

{media.playbackUrl}

+
+
+
+ + + setSource(event.target.value)} + placeholder="Source URL" + /> + + +
+
+ + +
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ); +} diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx new file mode 100644 index 0000000..0ba6e08 --- /dev/null +++ b/src/components/status-badge.tsx @@ -0,0 +1,5 @@ +import { clsx } from "clsx"; + +export function StatusBadge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: string }) { + return {children}; +} diff --git a/src/lib/access.ts b/src/lib/access.ts new file mode 100644 index 0000000..ee7c5ae --- /dev/null +++ b/src/lib/access.ts @@ -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; diff --git a/src/lib/media.ts b/src/lib/media.ts new file mode 100644 index 0000000..196ead7 --- /dev/null +++ b/src/lib/media.ts @@ -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; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..04517ec --- /dev/null +++ b/src/lib/prisma.ts @@ -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; +} diff --git a/src/lib/sample-data.ts b/src/lib/sample-data.ts new file mode 100644 index 0000000..114a9ff --- /dev/null +++ b/src/lib/sample-data.ts @@ -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" +]; diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..b6ca249 --- /dev/null +++ b/src/lib/session.ts @@ -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 } } } + }); +} diff --git a/src/lib/user-actions.ts b/src/lib/user-actions.ts new file mode 100644 index 0000000..02d4367 --- /dev/null +++ b/src/lib/user-actions.ts @@ -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"); +} diff --git a/tests/access.test.ts b/tests/access.test.ts new file mode 100644 index 0000000..5112bb5 --- /dev/null +++ b/tests/access.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { canEnterRoom } from "../src/lib/access"; + +describe("canEnterRoom", () => { + it("allows public rooms", () => { + expect(canEnterRoom({ visibility: "PUBLIC" })).toBe(true); + }); + + it("allows owners and admins regardless of room visibility", () => { + expect(canEnterRoom({ visibility: "EXPLICIT", isOwner: true })).toBe(true); + expect(canEnterRoom({ visibility: "ROLE_RESTRICTED", isAdmin: true })).toBe(true); + }); + + it("requires accepted friend or explicit membership for friends-only rooms", () => { + expect(canEnterRoom({ visibility: "FRIENDS" })).toBe(false); + expect(canEnterRoom({ visibility: "FRIENDS", isFriend: true })).toBe(true); + }); +}); diff --git a/tests/media.test.ts b/tests/media.test.ts new file mode 100644 index 0000000..5579d3b --- /dev/null +++ b/tests/media.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMediaUrl } from "../src/lib/media"; + +describe("normalizeMediaUrl", () => { + it("normalizes YouTube watch urls", () => { + const media = normalizeMediaUrl("https://www.youtube.com/watch?v=abc123"); + expect(media.provider).toBe("YOUTUBE"); + expect(media.playbackUrl).toContain("/embed/abc123"); + }); + + it("normalizes Twitch channels", () => { + const media = normalizeMediaUrl("https://www.twitch.tv/example"); + expect(media.provider).toBe("TWITCH"); + expect(media.playbackUrl).toContain("channel=example"); + }); + + it("detects direct video sources", () => { + const media = normalizeMediaUrl("https://cdn.example.com/video.mp4"); + expect(media.provider).toBe("DIRECT"); + expect(media.playbackUrl).toBe("https://cdn.example.com/video.mp4"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7bf1a8c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "es2022"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}