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
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export { Room } from "./durable-objects/room";

const app = new Hono<{ Bindings: Env }>();

app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.5" }));
app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.6" }));

app.route("/api/users", users);
app.route("/api/profile", profile);
Expand Down
30 changes: 30 additions & 0 deletions src/lib/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Minimal fixed-window rate limiter backed by KV. Best-effort (fails open on KV errors),
// good enough to stop casual spam of the public write endpoints (create user / room).
// For serious abuse, Cloudflare WAF rate-limiting rules are the proper tool.

import type { Env } from "../types";

/** Returns true if the action is allowed, false if over the limit for this window. */
export async function rateLimit(
env: Env,
id: string,
limit: number,
windowSec: number,
): Promise<boolean> {
const bucket = Math.floor(Date.now() / (windowSec * 1000));
const key = `rl:${id}:${bucket}`;
try {
const current = await env.TMDB_CACHE.get(key);
const count = current ? Number(current) : 0;
if (count >= limit) return false; // over limit → no write, so writes stay bounded
await env.TMDB_CACHE.put(key, String(count + 1), { expirationTtl: windowSec * 2 });
return true;
} catch {
return true; // fail open — never block real users on a KV hiccup
}
}

/** Client IP (Cloudflare-provided), for keying the limiter. */
export function clientIp(headers: Headers): string {
return headers.get("CF-Connecting-IP") || "anon";
}
4 changes: 4 additions & 0 deletions src/routes/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import { getDeckForUser, getCardFromDeck, resetDeck, buildMatchReason } from "..
import { getWatchProviders, getImdbId, getTrailerKey } from "../services/tmdb";
import { getOmdbRatings } from "../services/omdb";
import { createRequest } from "../services/overseerr";
import { rateLimit, clientIp } from "../lib/ratelimit";

export const rooms = new Hono<{ Bindings: Env }>();

// POST /api/rooms — create a room + invite code. The user becomes user_a.
// Body: { user_id, media_type?='movie', platform_filter?, solo?=false }
rooms.post("/", async (c) => {
if (!(await rateLimit(c.env, `rooms:${clientIp(c.req.raw.headers)}`, 30, 60))) {
return c.json({ error: "rate_limited" }, 429);
}
const body = await c.req.json().catch(() => null);
const userId = typeof body?.user_id === "string" ? body.user_id : "";
if (!userId) return c.json({ error: "user_id_required" }, 400);
Expand Down
4 changes: 4 additions & 0 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import type { Env, MediaType } from "../types";
import { genId } from "../lib/ids";
import { getTitleCard } from "../services/tmdb";
import { createRequest } from "../services/overseerr";
import { rateLimit, clientIp } from "../lib/ratelimit";

export const users = new Hono<{ Bindings: Env }>();

// POST /api/users — create a user.
// Body: { username: string }
users.post("/", async (c) => {
if (!(await rateLimit(c.env, `users:${clientIp(c.req.raw.headers)}`, 20, 60))) {
return c.json({ error: "rate_limited" }, 429);
}
const body = await c.req.json().catch(() => null);
const username = typeof body?.username === "string" ? body.username.trim() : "";
if (username.length < 1 || username.length > 40) {
Expand Down
Loading