diff --git a/src/atproto/routes.ts b/src/atproto/routes.ts index 6081e4f..c41da0f 100644 --- a/src/atproto/routes.ts +++ b/src/atproto/routes.ts @@ -3,6 +3,7 @@ import { OAUTH_SCOPE } from "../lib/constants.ts"; import { escapeHtml } from "../lib/html.ts"; import { fmt, resolveLocale, t } from "../lib/i18n/index.ts"; import { LIMITS } from "../lib/limits.ts"; +import { getClientIp, rateLimit } from "../lib/rate-limit.ts"; import { htmlResponse } from "../lib/response.ts"; import { loginPage } from "../views/login.ts"; import { getDevAccounts, getDevPdsUrl, getHandleResolverUrl } from "./env.ts"; @@ -63,6 +64,14 @@ export function atprotoRoutes() { return new Response(loginPage({ locale }), { headers }); }) .post("/login", async ({ request }) => { + const rl = LIMITS.rateLimit.login; + const limited = rateLimit( + `login:${getClientIp(request)}`, + rl.limit, + rl.windowMs, + ); + if (limited) return limited; + const locale = resolveLocale( request.headers.get("cookie"), request.headers.get("accept-language"), diff --git a/src/lib/limits.ts b/src/lib/limits.ts index 65d610d..c22fd44 100644 --- a/src/lib/limits.ts +++ b/src/lib/limits.ts @@ -47,4 +47,11 @@ export const LIMITS = { snippetRadius: 60, profileCacheHours: 24, sessionMaxAgeSecs: 604800, // 7 days + /** Per-IP rate limits: { max requests, window in ms } */ + rateLimit: { + upload: { limit: 10, windowMs: 60_000 }, + blobProxy: { limit: 60, windowMs: 60_000 }, + login: { limit: 5, windowMs: 60_000 }, + search: { limit: 30, windowMs: 60_000 }, + }, } as const; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..72e286c --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,48 @@ +interface WindowEntry { + count: number; + resetAt: number; +} + +const windows = new Map(); + +// Purge expired entries every 60s +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of windows) { + if (now > entry.resetAt) windows.delete(key); + } +}, 60_000).unref(); + +/** + * Fixed-window rate limiter. Returns null if allowed, + * or a 429 Response if the limit is exceeded. + */ +export function rateLimit( + key: string, + limit: number, + windowMs: number, +): Response | null { + const now = Date.now(); + const entry = windows.get(key); + if (!entry || now > entry.resetAt) { + windows.set(key, { count: 1, resetAt: now + windowMs }); + return null; + } + if (entry.count >= limit) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return new Response("Too many requests", { + status: 429, + headers: { "Retry-After": String(retryAfter) }, + }); + } + entry.count++; + return null; +} + +export function getClientIp(request: Request): string { + return ( + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown" + ); +} diff --git a/src/server/routes/blob.ts b/src/server/routes/blob.ts index eb013f1..5156329 100644 --- a/src/server/routes/blob.ts +++ b/src/server/routes/blob.ts @@ -9,6 +9,7 @@ import { formatError } from "../../lib/errors.ts"; import { resolvePdsEndpoint } from "../../lib/identity.ts"; import { processImage } from "../../lib/image.ts"; import { LIMITS } from "../../lib/limits.ts"; +import { getClientIp, rateLimit } from "../../lib/rate-limit.ts"; const LOCAL_BLOB_DIR = "data/blobs"; @@ -20,6 +21,14 @@ function ensureBlobDir(): void { export const blobRoutes = new Elysia() .post("/api/upload-image", async ({ request }) => { + const rl = LIMITS.rateLimit.upload; + const limited = rateLimit( + `upload:${getClientIp(request)}`, + rl.limit, + rl.windowMs, + ); + if (limited) return limited; + const formData = await request.formData(); const file = formData.get("file"); @@ -110,7 +119,15 @@ export const blobRoutes = new Elysia() }, }); }) - .get("/blob/:did/:cid", async ({ params }) => { + .get("/blob/:did/:cid", async ({ params, request }) => { + const rl = LIMITS.rateLimit.blobProxy; + const limited = rateLimit( + `blob:${getClientIp(request)}`, + rl.limit, + rl.windowMs, + ); + if (limited) return limited; + const { did, cid } = params; let pdsEndpoint: string; diff --git a/src/server/routes/search.ts b/src/server/routes/search.ts index 6b6d035..43589b1 100644 --- a/src/server/routes/search.ts +++ b/src/server/routes/search.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import { canRead, resolveRequestContext } from "../../lib/access.ts"; import { LIMITS } from "../../lib/limits.ts"; import { parsePage, parseSort } from "../../lib/query-params.ts"; +import { getClientIp, rateLimit } from "../../lib/rate-limit.ts"; import { htmlResponse } from "../../lib/response.ts"; import { renderNoteResults } from "../../views/search-results.ts"; import { @@ -19,6 +20,14 @@ function parseLimit(value: unknown): number { export const searchRoutes = new Elysia().get( "/search", async ({ query, request }) => { + const rl = LIMITS.rateLimit.search; + const limited = rateLimit( + `search:${getClientIp(request)}`, + rl.limit, + rl.windowMs, + ); + if (limited) return limited; + const q = (query["q"] as string | undefined) ?? ""; const wikiSlug = (query["wiki"] as string | undefined) || null; const lang = (query["lang"] as string | undefined) || null; diff --git a/src/views/settings.ts b/src/views/settings.ts index 145eb3d..17baa6e 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -38,6 +38,105 @@ function renderIdentity(did: string, profile: ProfileInfo | undefined): string { `; } +function roleLabelsFor(msg: ReturnType): Record { + return { + admin: msg.access.roleAdmin, + contributor: msg.access.roleContributor, + viewer: msg.access.roleViewer, + owner: msg.access.roleOwner, + }; +} + +function renderRoleOptions( + current: string, + roleLabel: (r: string) => string, +): string { + return ["contributor", "admin", "viewer"] + .map( + (r) => + ``, + ) + .join(""); +} + +function renderMemberRow( + m: MembershipRow, + wikiSlug: string, + wikiDid: string, + profile: ProfileInfo | undefined, + roleLabel: (r: string) => string, + msg: ReturnType, +): string { + const isOwner = m.did === wikiDid; + const roleCell = isOwner + ? `${roleLabel("owner")}` + : `
+ + +
`; + const removeButton = isOwner + ? "" + : `
+ +
`; + return ` + ${renderIdentity(m.did, profile)} + ${roleCell} + ${escapeHtml(m.created_at)} + ${removeButton} + `; +} + +function renderRequestRow( + r: RequestRow, + wikiSlug: string, + profile: ProfileInfo | undefined, + roleLabel: (r: string) => string, + msg: ReturnType, +): string { + return ` + ${renderIdentity(r.did, profile)} + ${escapeHtml(r.created_at)} + +
+ + +
+ + `; +} + +function renderAddMemberForm( + wikiSlug: string, + roleLabel: (r: string) => string, + msg: ReturnType, +): string { + return `

${msg.access.addMember}

+
+
+ + +
+
+ + +
+ +
`; +} + function renderMembersSection( wikiSlug: string, members: MembershipRow[], @@ -47,64 +146,25 @@ function renderMembersSection( locale: string, ): string { const msg = t(locale as "en" | "fr"); - - const roleLabels: Record = { - admin: msg.access.roleAdmin, - contributor: msg.access.roleContributor, - viewer: msg.access.roleViewer, - owner: msg.access.roleOwner, - }; - const roleLabel = (role: string) => roleLabels[role] ?? role; - - const roleOptions = (current: string) => - ["contributor", "admin", "viewer"] - .map( - (r) => - ``, - ) - .join(""); + const labels = roleLabelsFor(msg); + const roleLabel = (role: string) => labels[role] ?? role; const memberRows = members - .map((m) => { - const isOwner = m.did === wikiDid; - const roleCell = isOwner - ? `${roleLabel("owner")}` - : `
- - -
`; - const removeButton = isOwner - ? "" - : `
- -
`; - return ` - ${renderIdentity(m.did, profiles.get(m.did))} - ${roleCell} - ${escapeHtml(m.created_at)} - ${removeButton} - `; - }) + .map((m) => + renderMemberRow( + m, + wikiSlug, + wikiDid, + profiles.get(m.did), + roleLabel, + msg, + ), + ) .join("\n"); const requestRows = requests - .map( - (r) => ` - ${renderIdentity(r.did, profiles.get(r.did))} - ${escapeHtml(r.created_at)} - -
- - -
- - `, + .map((r) => + renderRequestRow(r, wikiSlug, profiles.get(r.did), roleLabel, msg), ) .join("\n"); @@ -145,24 +205,7 @@ function renderMembersSection( : "" } -

${msg.access.addMember}

-
-
- - -
-
- - -
- -
+ ${renderAddMemberForm(wikiSlug, roleLabel, msg)} `; }