Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 10 additions & 0 deletions apps/web/src/app/api/import/youtube/route.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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();

Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app/api/rooms/route.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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());

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/room-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";

interface Counter {
count: number;
resetAt: number;
}

const store =
(globalThis as typeof globalThis & { __togetherRateLimit?: Map<string, Counter> })
.__togetherRateLimit ?? new Map<string, Counter>();
(
globalThis as typeof globalThis & { __togetherRateLimit?: Map<string, Counter> }
).__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) },
},
);
}
Loading