🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add rate limits

+205 -72
+9
src/atproto/routes.ts
··· 3 3 import { escapeHtml } from "../lib/html.ts"; 4 4 import { fmt, resolveLocale, t } from "../lib/i18n/index.ts"; 5 5 import { LIMITS } from "../lib/limits.ts"; 6 + import { getClientIp, rateLimit } from "../lib/rate-limit.ts"; 6 7 import { htmlResponse } from "../lib/response.ts"; 7 8 import { loginPage } from "../views/login.ts"; 8 9 import { getDevAccounts, getDevPdsUrl, getHandleResolverUrl } from "./env.ts"; ··· 63 64 return new Response(loginPage({ locale }), { headers }); 64 65 }) 65 66 .post("/login", async ({ request }) => { 67 + const rl = LIMITS.rateLimit.login; 68 + const limited = rateLimit( 69 + `login:${getClientIp(request)}`, 70 + rl.limit, 71 + rl.windowMs, 72 + ); 73 + if (limited) return limited; 74 + 66 75 const locale = resolveLocale( 67 76 request.headers.get("cookie"), 68 77 request.headers.get("accept-language"),
+7
src/lib/limits.ts
··· 47 47 snippetRadius: 60, 48 48 profileCacheHours: 24, 49 49 sessionMaxAgeSecs: 604800, // 7 days 50 + /** Per-IP rate limits: { max requests, window in ms } */ 51 + rateLimit: { 52 + upload: { limit: 10, windowMs: 60_000 }, 53 + blobProxy: { limit: 60, windowMs: 60_000 }, 54 + login: { limit: 5, windowMs: 60_000 }, 55 + search: { limit: 30, windowMs: 60_000 }, 56 + }, 50 57 } as const;
+48
src/lib/rate-limit.ts
··· 1 + interface WindowEntry { 2 + count: number; 3 + resetAt: number; 4 + } 5 + 6 + const windows = new Map<string, WindowEntry>(); 7 + 8 + // Purge expired entries every 60s 9 + setInterval(() => { 10 + const now = Date.now(); 11 + for (const [key, entry] of windows) { 12 + if (now > entry.resetAt) windows.delete(key); 13 + } 14 + }, 60_000).unref(); 15 + 16 + /** 17 + * Fixed-window rate limiter. Returns null if allowed, 18 + * or a 429 Response if the limit is exceeded. 19 + */ 20 + export function rateLimit( 21 + key: string, 22 + limit: number, 23 + windowMs: number, 24 + ): Response | null { 25 + const now = Date.now(); 26 + const entry = windows.get(key); 27 + if (!entry || now > entry.resetAt) { 28 + windows.set(key, { count: 1, resetAt: now + windowMs }); 29 + return null; 30 + } 31 + if (entry.count >= limit) { 32 + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); 33 + return new Response("Too many requests", { 34 + status: 429, 35 + headers: { "Retry-After": String(retryAfter) }, 36 + }); 37 + } 38 + entry.count++; 39 + return null; 40 + } 41 + 42 + export function getClientIp(request: Request): string { 43 + return ( 44 + request.headers.get("cf-connecting-ip") ?? 45 + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? 46 + "unknown" 47 + ); 48 + }
+18 -1
src/server/routes/blob.ts
··· 9 9 import { resolvePdsEndpoint } from "../../lib/identity.ts"; 10 10 import { processImage } from "../../lib/image.ts"; 11 11 import { LIMITS } from "../../lib/limits.ts"; 12 + import { getClientIp, rateLimit } from "../../lib/rate-limit.ts"; 12 13 13 14 const LOCAL_BLOB_DIR = "data/blobs"; 14 15 ··· 20 21 21 22 export const blobRoutes = new Elysia() 22 23 .post("/api/upload-image", async ({ request }) => { 24 + const rl = LIMITS.rateLimit.upload; 25 + const limited = rateLimit( 26 + `upload:${getClientIp(request)}`, 27 + rl.limit, 28 + rl.windowMs, 29 + ); 30 + if (limited) return limited; 31 + 23 32 const formData = await request.formData(); 24 33 const file = formData.get("file"); 25 34 ··· 110 119 }, 111 120 }); 112 121 }) 113 - .get("/blob/:did/:cid", async ({ params }) => { 122 + .get("/blob/:did/:cid", async ({ params, request }) => { 123 + const rl = LIMITS.rateLimit.blobProxy; 124 + const limited = rateLimit( 125 + `blob:${getClientIp(request)}`, 126 + rl.limit, 127 + rl.windowMs, 128 + ); 129 + if (limited) return limited; 130 + 114 131 const { did, cid } = params; 115 132 116 133 let pdsEndpoint: string;
+9
src/server/routes/search.ts
··· 2 2 import { canRead, resolveRequestContext } from "../../lib/access.ts"; 3 3 import { LIMITS } from "../../lib/limits.ts"; 4 4 import { parsePage, parseSort } from "../../lib/query-params.ts"; 5 + import { getClientIp, rateLimit } from "../../lib/rate-limit.ts"; 5 6 import { htmlResponse } from "../../lib/response.ts"; 6 7 import { renderNoteResults } from "../../views/search-results.ts"; 7 8 import { ··· 19 20 export const searchRoutes = new Elysia().get( 20 21 "/search", 21 22 async ({ query, request }) => { 23 + const rl = LIMITS.rateLimit.search; 24 + const limited = rateLimit( 25 + `search:${getClientIp(request)}`, 26 + rl.limit, 27 + rl.windowMs, 28 + ); 29 + if (limited) return limited; 30 + 22 31 const q = (query["q"] as string | undefined) ?? ""; 23 32 const wikiSlug = (query["wiki"] as string | undefined) || null; 24 33 const lang = (query["lang"] as string | undefined) || null;
+114 -71
src/views/settings.ts
··· 38 38 </span>`; 39 39 } 40 40 41 + function roleLabelsFor(msg: ReturnType<typeof t>): Record<string, string> { 42 + return { 43 + admin: msg.access.roleAdmin, 44 + contributor: msg.access.roleContributor, 45 + viewer: msg.access.roleViewer, 46 + owner: msg.access.roleOwner, 47 + }; 48 + } 49 + 50 + function renderRoleOptions( 51 + current: string, 52 + roleLabel: (r: string) => string, 53 + ): string { 54 + return ["contributor", "admin", "viewer"] 55 + .map( 56 + (r) => 57 + `<option value="${r}"${r === current ? " selected" : ""}>${roleLabel(r)}</option>`, 58 + ) 59 + .join(""); 60 + } 61 + 62 + function renderMemberRow( 63 + m: MembershipRow, 64 + wikiSlug: string, 65 + wikiDid: string, 66 + profile: ProfileInfo | undefined, 67 + roleLabel: (r: string) => string, 68 + msg: ReturnType<typeof t>, 69 + ): string { 70 + const isOwner = m.did === wikiDid; 71 + const roleCell = isOwner 72 + ? `<span class="text-sm">${roleLabel("owner")}</span>` 73 + : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/change-role" class="inline-flex items-center gap-1"> 74 + <select name="role" aria-label="${msg.access.role}" data-original="${m.role}" onchange="this.nextElementSibling.classList.toggle('hidden', this.value === this.dataset.original)" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 75 + ${renderRoleOptions(m.role, roleLabel)} 76 + </select> 77 + <button type="submit" class="hidden p-1 ${THEME.accentBg} text-white rounded ${THEME.accentDarkHoverBg} cursor-pointer" title="${msg.access.saveRole}">${ICONS.check}</button> 78 + </form>`; 79 + const removeButton = isOwner 80 + ? "" 81 + : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/remove" class="inline"> 82 + <button type="submit" class="${dangerSmallButtonClass} cursor-pointer">${msg.access.remove}</button> 83 + </form>`; 84 + return `<tr class="border-b ${THEME.borderSubtle}"> 85 + <td class="py-2 pr-4">${renderIdentity(m.did, profile)}</td> 86 + <td class="py-2 pr-4">${roleCell}</td> 87 + <td class="py-2 pr-4 text-sm ${THEME.textMuted}">${escapeHtml(m.created_at)}</td> 88 + <td class="py-2 text-sm">${removeButton}</td> 89 + </tr>`; 90 + } 91 + 92 + function renderRequestRow( 93 + r: RequestRow, 94 + wikiSlug: string, 95 + profile: ProfileInfo | undefined, 96 + roleLabel: (r: string) => string, 97 + msg: ReturnType<typeof t>, 98 + ): string { 99 + return `<tr class="border-b ${THEME.borderSubtle}"> 100 + <td class="py-2 pr-4">${renderIdentity(r.did, profile)}</td> 101 + <td class="py-2 pr-4 text-sm ${THEME.textMuted}">${escapeHtml(r.created_at)}</td> 102 + <td class="py-2 text-sm"> 103 + <form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(r.did)}/approve" class="inline-flex items-center gap-2"> 104 + <select name="role" aria-label="${msg.access.role}" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 105 + <option value="contributor">${roleLabel("contributor")}</option> 106 + <option value="admin">${roleLabel("admin")}</option> 107 + <option value="viewer">${roleLabel("viewer")}</option> 108 + </select> 109 + <button type="submit" class="${primarySmallButtonClass} cursor-pointer">${msg.access.approve}</button> 110 + </form> 111 + </td> 112 + </tr>`; 113 + } 114 + 115 + function renderAddMemberForm( 116 + wikiSlug: string, 117 + roleLabel: (r: string) => string, 118 + msg: ReturnType<typeof t>, 119 + ): string { 120 + return `<h3 class="text-base font-semibold mb-3">${msg.access.addMember}</h3> 121 + <form method="POST" action="/wiki/${wikiSlug}/-/members/add" class="flex items-end gap-3 flex-wrap"> 122 + <div class="flex flex-col gap-1"> 123 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.addMemberLabel}</label> 124 + <input type="text" name="did" required autocomplete="off" 125 + placeholder="${msg.access.addMemberPlaceholder}" 126 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder} w-72" /> 127 + </div> 128 + <div class="flex flex-col gap-1"> 129 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.role}</label> 130 + <select name="role" class="text-sm border ${THEME.borderInput} rounded px-2 py-1.5 focus:outline-none ${THEME.accentInputFocusBorder}"> 131 + <option value="contributor">${roleLabel("contributor")}</option> 132 + <option value="admin">${roleLabel("admin")}</option> 133 + <option value="viewer">${roleLabel("viewer")}</option> 134 + </select> 135 + </div> 136 + <button type="submit" class="${primarySmallButtonClass}">${msg.access.add}</button> 137 + </form>`; 138 + } 139 + 41 140 function renderMembersSection( 42 141 wikiSlug: string, 43 142 members: MembershipRow[], ··· 47 146 locale: string, 48 147 ): string { 49 148 const msg = t(locale as "en" | "fr"); 50 - 51 - const roleLabels: Record<string, string> = { 52 - admin: msg.access.roleAdmin, 53 - contributor: msg.access.roleContributor, 54 - viewer: msg.access.roleViewer, 55 - owner: msg.access.roleOwner, 56 - }; 57 - const roleLabel = (role: string) => roleLabels[role] ?? role; 58 - 59 - const roleOptions = (current: string) => 60 - ["contributor", "admin", "viewer"] 61 - .map( 62 - (r) => 63 - `<option value="${r}"${r === current ? " selected" : ""}>${roleLabel(r)}</option>`, 64 - ) 65 - .join(""); 149 + const labels = roleLabelsFor(msg); 150 + const roleLabel = (role: string) => labels[role] ?? role; 66 151 67 152 const memberRows = members 68 - .map((m) => { 69 - const isOwner = m.did === wikiDid; 70 - const roleCell = isOwner 71 - ? `<span class="text-sm">${roleLabel("owner")}</span>` 72 - : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/change-role" class="inline-flex items-center gap-1"> 73 - <select name="role" aria-label="${msg.access.role}" data-original="${m.role}" onchange="this.nextElementSibling.classList.toggle('hidden', this.value === this.dataset.original)" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 74 - ${roleOptions(m.role)} 75 - </select> 76 - <button type="submit" class="hidden p-1 ${THEME.accentBg} text-white rounded ${THEME.accentDarkHoverBg} cursor-pointer" title="${msg.access.saveRole}">${ICONS.check}</button> 77 - </form>`; 78 - const removeButton = isOwner 79 - ? "" 80 - : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/remove" class="inline"> 81 - <button type="submit" class="${dangerSmallButtonClass} cursor-pointer">${msg.access.remove}</button> 82 - </form>`; 83 - return `<tr class="border-b ${THEME.borderSubtle}"> 84 - <td class="py-2 pr-4">${renderIdentity(m.did, profiles.get(m.did))}</td> 85 - <td class="py-2 pr-4">${roleCell}</td> 86 - <td class="py-2 pr-4 text-sm ${THEME.textMuted}">${escapeHtml(m.created_at)}</td> 87 - <td class="py-2 text-sm">${removeButton}</td> 88 - </tr>`; 89 - }) 153 + .map((m) => 154 + renderMemberRow( 155 + m, 156 + wikiSlug, 157 + wikiDid, 158 + profiles.get(m.did), 159 + roleLabel, 160 + msg, 161 + ), 162 + ) 90 163 .join("\n"); 91 164 92 165 const requestRows = requests 93 - .map( 94 - (r) => `<tr class="border-b ${THEME.borderSubtle}"> 95 - <td class="py-2 pr-4">${renderIdentity(r.did, profiles.get(r.did))}</td> 96 - <td class="py-2 pr-4 text-sm ${THEME.textMuted}">${escapeHtml(r.created_at)}</td> 97 - <td class="py-2 text-sm"> 98 - <form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(r.did)}/approve" class="inline-flex items-center gap-2"> 99 - <select name="role" aria-label="${msg.access.role}" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 100 - <option value="contributor">${roleLabel("contributor")}</option> 101 - <option value="admin">${roleLabel("admin")}</option> 102 - <option value="viewer">${roleLabel("viewer")}</option> 103 - </select> 104 - <button type="submit" class="${primarySmallButtonClass} cursor-pointer">${msg.access.approve}</button> 105 - </form> 106 - </td> 107 - </tr>`, 166 + .map((r) => 167 + renderRequestRow(r, wikiSlug, profiles.get(r.did), roleLabel, msg), 108 168 ) 109 169 .join("\n"); 110 170 ··· 145 205 : "" 146 206 } 147 207 148 - <h3 class="text-base font-semibold mb-3">${msg.access.addMember}</h3> 149 - <form method="POST" action="/wiki/${wikiSlug}/-/members/add" class="flex items-end gap-3 flex-wrap"> 150 - <div class="flex flex-col gap-1"> 151 - <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.addMemberLabel}</label> 152 - <input type="text" name="did" required autocomplete="off" 153 - placeholder="${msg.access.addMemberPlaceholder}" 154 - class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder} w-72" /> 155 - </div> 156 - <div class="flex flex-col gap-1"> 157 - <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.role}</label> 158 - <select name="role" class="text-sm border ${THEME.borderInput} rounded px-2 py-1.5 focus:outline-none ${THEME.accentInputFocusBorder}"> 159 - <option value="contributor">${roleLabel("contributor")}</option> 160 - <option value="admin">${roleLabel("admin")}</option> 161 - <option value="viewer">${roleLabel("viewer")}</option> 162 - </select> 163 - </div> 164 - <button type="submit" class="${primarySmallButtonClass}">${msg.access.add}</button> 165 - </form> 208 + ${renderAddMemberForm(wikiSlug, roleLabel, msg)} 166 209 </section>`; 167 210 } 168 211