diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..904768a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,42 @@ +name: Deploy + +# Deploys the Cloudflare Worker (services/realtime) after CI passes on main. +# The Next.js web app (apps/web) deploys automatically via the Vercel Git +# integration on push to main — no workflow step is needed here. Configure the +# Vercel project to point at apps/web and set its env vars in the Vercel +# dashboard (DATABASE_URL, YOUTUBE_API_KEY, NEXT_PUBLIC_*, ROOM_TOKEN_SECRET, …). +# +# Required repository secrets for the worker deploy: +# CLOUDFLARE_API_TOKEN - token with "Edit Cloudflare Workers" permission +# CLOUDFLARE_ACCOUNT_ID - target Cloudflare account id + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + +concurrency: + group: deploy-realtime + cancel-in-progress: false + +jobs: + deploy-realtime: + # Only deploy when the CI run that triggered this succeeded. + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Deploy Cloudflare Worker + run: pnpm --filter @together/realtime deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/apps/web/src/app/api/import/youtube/route.ts b/apps/web/src/app/api/import/youtube/route.ts index d2a4cc7..e6a1658 100644 --- a/apps/web/src/app/api/import/youtube/route.ts +++ b/apps/web/src/app/api/import/youtube/route.ts @@ -1,8 +1,15 @@ import { NextResponse } from "next/server"; import { getYouTubeClient, importYouTubeUrl } from "@/lib/youtube"; import { parseYouTubePlaylistId, parseYouTubeVideoId } from "@together/track-resolver"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { z } from "zod"; +const importRateLimit = { + name: "import:youtube", + limit: 30, + windowMs: 60 * 1000, +}; + const schema = z.object({ url: z.string().min(1).optional(), query: z.string().min(1).optional(), @@ -17,6 +24,9 @@ function isYouTubeUrl(input: string): boolean { } export async function POST(request: Request) { + const limited = enforceRateLimit(request, importRateLimit); + if (limited) return limited; + const body = schema.parse(await request.json()); const input = (body.url ?? body.query ?? "").trim(); diff --git a/apps/web/src/app/api/rooms/route.ts b/apps/web/src/app/api/rooms/route.ts index d2da195..319f741 100644 --- a/apps/web/src/app/api/rooms/route.ts +++ b/apps/web/src/app/api/rooms/route.ts @@ -1,8 +1,15 @@ import { NextResponse } from "next/server"; import { createRoom } from "@/lib/rooms"; import { roomPasswordCookieName, cookieOptions } from "@/lib/room-access"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { z } from "zod"; +const createRoomRateLimit = { + name: "rooms:create", + limit: 10, + windowMs: 60 * 60 * 1000, +}; + const createRoomSchema = z.object({ displayName: z.string().min(1).max(24), title: z.string().min(1).max(64).trim().optional(), @@ -13,6 +20,9 @@ const createRoomSchema = z.object({ }); export async function POST(request: Request) { + const limited = enforceRateLimit(request, createRoomRateLimit); + if (limited) return limited; + try { const body = createRoomSchema.parse(await request.json()); diff --git a/apps/web/src/components/room-client.tsx b/apps/web/src/components/room-client.tsx index 8eb6d38..ab61885 100644 --- a/apps/web/src/components/room-client.tsx +++ b/apps/web/src/components/room-client.tsx @@ -836,7 +836,11 @@ export function RoomClient({ hasOwner={hasOwner} onRoomUpdate={(s) => { send({ type: "settings:update", settings: s }); - if (hasOwner && isHost) { + // Persist to the DB so it stays in sync with the Durable Object. + // The settings route authorizes ownership server-side (owner-only + // for claimed rooms), which also keeps settings durable for rooms + // claimed later in the session. + if (isHost) { void fetch(`/api/rooms/${slug}/settings`, { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts new file mode 100644 index 0000000..0fe1e3a --- /dev/null +++ b/apps/web/src/lib/rate-limit.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; + +interface Counter { + count: number; + resetAt: number; +} + +const store = + (globalThis as typeof globalThis & { __togetherRateLimit?: Map }) + .__togetherRateLimit ?? new Map(); +( + globalThis as typeof globalThis & { __togetherRateLimit?: Map } +).__togetherRateLimit = store; + +export interface RateLimitRule { + /** Logical bucket name, keeps different routes from sharing counters. */ + name: string; + /** Max requests allowed per window. */ + limit: number; + /** Window length in milliseconds. */ + windowMs: number; +} + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + /** Seconds until the window resets (for Retry-After). */ + retryAfterSeconds: number; +} + +/** Best-effort client IP from common proxy headers (Vercel sets x-forwarded-for). */ +export function getClientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + return request.headers.get("x-real-ip")?.trim() || "unknown"; +} + +/** + * Fixed-window, in-memory rate limiter. Suitable for single-instance dev and + * best-effort protection on serverless (per-instance). Swap for a shared store + * (Redis/Upstash) if strict global limits are needed. + */ +export function checkRateLimit(key: string, rule: RateLimitRule): RateLimitResult { + const now = Date.now(); + const storeKey = `${rule.name}:${key}`; + const existing = store.get(storeKey); + + if (!existing || existing.resetAt <= now) { + store.set(storeKey, { count: 1, resetAt: now + rule.windowMs }); + return { allowed: true, remaining: rule.limit - 1, retryAfterSeconds: 0 }; + } + + if (existing.count >= rule.limit) { + return { + allowed: false, + remaining: 0, + retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)), + }; + } + + existing.count += 1; + return { + allowed: true, + remaining: rule.limit - existing.count, + retryAfterSeconds: 0, + }; +} + +/** + * Enforce a rate limit for an incoming request. Returns a 429 `NextResponse` + * when the limit is exceeded, or `null` when the request may proceed. + */ +export function enforceRateLimit(request: Request, rule: RateLimitRule): NextResponse | null { + const result = checkRateLimit(getClientIp(request), rule); + if (result.allowed) return null; + + return NextResponse.json( + { + error: `Too many requests. Try again in ${result.retryAfterSeconds}s.`, + }, + { + status: 429, + headers: { "Retry-After": String(result.retryAfterSeconds) }, + }, + ); +}