extract constants
Msrc/constants.ts
| @@ -26,6 +26,38 @@ export const VALID_KEY_TYPES = new Set([ | |||
|---|---|---|---|
| 26 | 26 | ]); | |
| 27 | 27 | export const CHALLENGE_TTL_MS = 5 * 60 * 1000; | |
| 28 | 28 | ||
| 29 | + | // Rate limiting | |
| 30 | + | export const LOGIN_MAX_ATTEMPTS = 10; | |
| 31 | + | export const LOGIN_RATE_WINDOW_MS = 60_000; | |
| 32 | + | export const REGISTRATION_MAX_ATTEMPTS = 3; | |
| 33 | + | export const REGISTRATION_RATE_WINDOW_MS = 60 * 60_000; | |
| 34 | + | ||
| 35 | + | // Session | |
| 36 | + | export const SESSION_ID_BYTES = 32; | |
| 37 | + | export const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; | |
| 38 | + | export const SESSION_DURATION_SECONDS = 30 * 24 * 60 * 60; | |
| 39 | + | export const MIN_PASSWORD_LENGTH = 8; | |
| 40 | + | ||
| 41 | + | // Cookie lifetimes | |
| 42 | + | export const YEAR_SECONDS = 365 * 24 * 60 * 60; | |
| 43 | + | ||
| 44 | + | // File handling | |
| 45 | + | export const BINARY_DETECT_BYTES = 8000; | |
| 46 | + | ||
| 47 | + | // Git ref limits | |
| 48 | + | export const MAX_REF_LIST = 1000; | |
| 49 | + | ||
| 50 | + | // Text preview | |
| 51 | + | export const PREVIEW_MAX_LENGTH = 180; | |
| 52 | + | export const PREVIEW_TRUNCATION_THRESHOLD = 0.6; | |
| 53 | + | ||
| 54 | + | // String length limits | |
| 55 | + | export const MAX_BRANCH_NAME_LENGTH = 255; | |
| 56 | + | export const MAX_TAG_NAME_LENGTH = 255; | |
| 57 | + | export const MAX_TAG_MESSAGE_LENGTH = 500; | |
| 58 | + | export const MAX_LABEL_NAME_LENGTH = 50; | |
| 59 | + | export const MAX_FILE_PATH_LENGTH = 1000; | |
| 60 | + | ||
| 29 | 61 | // Pagination | |
| 30 | 62 | export const REPOS_PER_PAGE = 20; | |
| 31 | 63 | export const COMMITS_PER_PAGE = 20; | |
Msrc/routes/auth.tsx
| @@ -10,6 +10,14 @@ import config from "../config.ts"; | |||
|---|---|---|---|
| 10 | 10 | import { | |
| 11 | 11 | ADMIN_USERNAME, | |
| 12 | 12 | CHALLENGE_TTL_MS, | |
| 13 | + | LOGIN_MAX_ATTEMPTS, | |
| 14 | + | LOGIN_RATE_WINDOW_MS, | |
| 15 | + | MIN_PASSWORD_LENGTH, | |
| 16 | + | REGISTRATION_MAX_ATTEMPTS, | |
| 17 | + | REGISTRATION_RATE_WINDOW_MS, | |
| 18 | + | SESSION_DURATION_MS, | |
| 19 | + | SESSION_DURATION_SECONDS, | |
| 20 | + | SESSION_ID_BYTES, | |
| 13 | 21 | VALID_USERNAME_RE, | |
| 14 | 22 | WEBAUTHN_RP_NAME, | |
| 15 | 23 | } from "../constants.ts"; | |
| @@ -36,9 +44,9 @@ function randomHex(bytes: number): string { | |||
|---|---|---|---|
| 36 | 44 | } | |
| 37 | 45 | ||
| 38 | 46 | async function createSession(userId: number): Promise<string> { | |
| 39 | - | const id = randomHex(32); | |
| 47 | + | const id = randomHex(SESSION_ID_BYTES); | |
| 40 | 48 | const now = new Date(); | |
| 41 | - | const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days | |
| 49 | + | const expires = new Date(now.getTime() + SESSION_DURATION_MS); | |
| 42 | 50 | await db | |
| 43 | 51 | .insertInto("sessions") | |
| 44 | 52 | .values({ | |
| @@ -52,7 +60,7 @@ async function createSession(userId: number): Promise<string> { | |||
|---|---|---|---|
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | 62 | function sessionCookie(id: string): string { | |
| 55 | - | return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`; | |
| 63 | + | return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DURATION_SECONDS}`; | |
| 56 | 64 | } | |
| 57 | 65 | ||
| 58 | 66 | function clearCookie(): string { | |
| @@ -94,7 +102,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 94 | 102 | "/login", | |
| 95 | 103 | async ({ body, request, server }) => { | |
| 96 | 104 | const ip = getClientIp(request, server); | |
| 97 | - | if (!checkRateLimit(ip, 10, 60_000)) { | |
| 105 | + | if (!checkRateLimit(ip, LOGIN_MAX_ATTEMPTS, LOGIN_RATE_WINDOW_MS)) { | |
| 98 | 106 | return html( | |
| 99 | 107 | <Login error="Too many login attempts. Please try again later." />, | |
| 100 | 108 | ); | |
| @@ -143,7 +151,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 143 | 151 | status: 403, | |
| 144 | 152 | }); | |
| 145 | 153 | const ip = getClientIp(request, server); | |
| 146 | - | if (!checkRateLimit(ip, 3, 60 * 60_000)) { | |
| 154 | + | if (!checkRateLimit(ip, REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS)) { | |
| 147 | 155 | return html( | |
| 148 | 156 | <Register | |
| 149 | 157 | error="Too many registration attempts. Please try again later." | |
| @@ -186,7 +194,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 186 | 194 | />, | |
| 187 | 195 | ); | |
| 188 | 196 | } | |
| 189 | - | if (password.length < 8) { | |
| 197 | + | if (password.length < MIN_PASSWORD_LENGTH) { | |
| 190 | 198 | return html( | |
| 191 | 199 | <Register | |
| 192 | 200 | error="Password must be at least 8 characters" | |
Msrc/routes/avatars.ts
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | import config from "../config.ts"; | |
| 3 | + | import { YEAR_SECONDS } from "../constants.ts"; | |
| 3 | 4 | import { db } from "../db/index.ts"; | |
| 4 | 5 | import { requireAuth, resolveSession } from "../middleware/session.ts"; | |
| 5 | 6 | import { | |
| @@ -8,7 +9,7 @@ import { | |||
|---|---|---|---|
| 8 | 9 | processAndStoreAvatar, | |
| 9 | 10 | } from "../services/avatar.ts"; | |
| 10 | 11 | ||
| 11 | - | const CACHE = "public, max-age=31536000, immutable"; | |
| 12 | + | const CACHE = `public, max-age=${YEAR_SECONDS}, immutable`; | |
| 12 | 13 | ||
| 13 | 14 | async function bumpAvatarVersion(userId: number): Promise<void> { | |
| 14 | 15 | await db | |
Msrc/routes/repos.tsx
| @@ -5,12 +5,16 @@ import { fileTypeFromBuffer } from "file-type"; | |||
|---|---|---|---|
| 5 | 5 | import { sql } from "kysely"; | |
| 6 | 6 | import config from "../config.ts"; | |
| 7 | 7 | import { | |
| 8 | + | BINARY_DETECT_BYTES, | |
| 8 | 9 | BRANCHES_PER_PAGE, | |
| 9 | 10 | COMMITS_PER_PAGE, | |
| 11 | + | MAX_BRANCH_NAME_LENGTH, | |
| 12 | + | MAX_LABEL_NAME_LENGTH, | |
| 10 | 13 | paths, | |
| 11 | 14 | REPOS_PER_PAGE, | |
| 12 | 15 | TAGS_PER_PAGE, | |
| 13 | 16 | VALID_REPO_NAME_RE, | |
| 17 | + | YEAR_SECONDS, | |
| 14 | 18 | } from "../constants.ts"; | |
| 15 | 19 | import { db } from "../db/index.ts"; | |
| 16 | 20 | import { redirect } from "../lib/redirect.ts"; | |
| @@ -56,7 +60,7 @@ async function mimeForContent( | |||
|---|---|---|---|
| 56 | 60 | return typeFromName; | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 59 | - | return hasBinaryContent(content.subarray(0, 8000)) | |
| 63 | + | return hasBinaryContent(content.subarray(0, BINARY_DETECT_BYTES)) | |
| 60 | 64 | ? "application/octet-stream" | |
| 61 | 65 | : "text/plain; charset=utf-8"; | |
| 62 | 66 | } | |
| @@ -97,7 +101,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 97 | 101 | const sort = body.sort === "name" ? "name" : "created"; | |
| 98 | 102 | return redirect( | |
| 99 | 103 | "/", | |
| 100 | - | `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`, | |
| 104 | + | `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`, | |
| 101 | 105 | ); | |
| 102 | 106 | }, | |
| 103 | 107 | { body: t.Object({ sort: t.String() }) }, | |
| @@ -864,7 +868,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 864 | 868 | const name = body.name?.trim(); | |
| 865 | 869 | const color = body.color?.trim(); | |
| 866 | 870 | ||
| 867 | - | if (!name || name.length > 50) { | |
| 871 | + | if (!name || name.length > MAX_LABEL_NAME_LENGTH) { | |
| 868 | 872 | return redirect( | |
| 869 | 873 | `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`, | |
| 870 | 874 | ); | |
| @@ -980,7 +984,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 980 | 984 | !name || | |
| 981 | 985 | !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) || | |
| 982 | 986 | name.includes("..") || | |
| 983 | - | name.length > 255 | |
| 987 | + | name.length > MAX_BRANCH_NAME_LENGTH | |
| 984 | 988 | ) { | |
| 985 | 989 | return redirect( | |
| 986 | 990 | `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`, | |
| @@ -1077,7 +1081,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 1077 | 1081 | !newName || | |
| 1078 | 1082 | !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) || | |
| 1079 | 1083 | newName.includes("..") || | |
| 1080 | - | newName.length > 255 | |
| 1084 | + | newName.length > MAX_BRANCH_NAME_LENGTH | |
| 1081 | 1085 | ) { | |
| 1082 | 1086 | return redirect( | |
| 1083 | 1087 | `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`, | |
Msrc/routes/settings.tsx
| @@ -6,6 +6,7 @@ import { | |||
|---|---|---|---|
| 6 | 6 | ADMIN_USERNAME, | |
| 7 | 7 | VALID_KEY_TYPES, | |
| 8 | 8 | VALID_USERNAME_RE, | |
| 9 | + | YEAR_SECONDS, | |
| 9 | 10 | } from "../constants.ts"; | |
| 10 | 11 | import { db } from "../db"; | |
| 11 | 12 | import { redirect } from "../lib/redirect.ts"; | |
| @@ -267,7 +268,7 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 267 | 268 | return redirect("/settings?error=Invalid+theme"); | |
| 268 | 269 | } | |
| 269 | 270 | ||
| 270 | - | const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`; | |
| 271 | + | const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`; | |
| 271 | 272 | return redirect("/settings?success=theme", cookieHeader); | |
| 272 | 273 | }, | |
| 273 | 274 | { | |
Msrc/services/git.ts
| @@ -3,6 +3,7 @@ import { $ as _$ } from "bun"; | |||
|---|---|---|---|
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | 5 | MAX_BRANCH_CACHE, | |
| 6 | + | MAX_REF_LIST, | |
| 6 | 7 | MAX_TAG_CACHE, | |
| 7 | 8 | paths, | |
| 8 | 9 | REF_CACHE_TTL_MS, | |
| @@ -420,7 +421,7 @@ export const git = { | |||
|---|---|---|---|
| 420 | 421 | ||
| 421 | 422 | async branchesWithInfo( | |
| 422 | 423 | name: string, | |
| 423 | - | maxCount = 1000, | |
| 424 | + | maxCount = MAX_REF_LIST, | |
| 424 | 425 | ): Promise<BranchInfo[]> { | |
| 425 | 426 | const p = repoPath(name); | |
| 426 | 427 | try { | |
Msrc/services/highlight.ts
| @@ -8,7 +8,7 @@ import { | |||
|---|---|---|---|
| 8 | 8 | type Highlighter, | |
| 9 | 9 | } from "shiki"; | |
| 10 | 10 | import config from "../config.ts"; | |
| 11 | - | import { MAX_FILE_CACHE } from "../constants.ts"; | |
| 11 | + | import { BINARY_DETECT_BYTES, MAX_FILE_CACHE } from "../constants.ts"; | |
| 12 | 12 | ||
| 13 | 13 | let highlighter: Highlighter | null = null; | |
| 14 | 14 | let extToLangId: Map<string, string> | null = null; | |
| @@ -72,7 +72,7 @@ export function getHighlighter(): Highlighter { | |||
|---|---|---|---|
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | export function hasBinaryContent(buf: Buffer): boolean { | |
| 75 | - | return buf.subarray(0, 8000).includes(0); | |
| 75 | + | return buf.subarray(0, BINARY_DETECT_BYTES).includes(0); | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | 78 | export function detectLang(filename: string): string { | |
Msrc/services/markdown.ts
| @@ -1,6 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import DOMPurify from "isomorphic-dompurify"; | |
| 2 | 2 | import { Marked, marked, type Tokens } from "marked"; | |
| 3 | - | import { MAX_MD_CACHE } from "../constants.ts"; | |
| 3 | + | import { MAX_MD_CACHE, PREVIEW_MAX_LENGTH, PREVIEW_TRUNCATION_THRESHOLD } from "../constants.ts"; | |
| 4 | 4 | ||
| 5 | 5 | marked.setOptions({ gfm: true }); | |
| 6 | 6 | ||
| @@ -181,14 +181,14 @@ export function markdownToPlaintext(md: string): string { | |||
|---|---|---|---|
| 181 | 181 | * Returns a short single-line preview of a plaintext string: | |
| 182 | 182 | * the first paragraph/heading line, truncated to maxLen chars. | |
| 183 | 183 | */ | |
| 184 | - | export function plaintextPreview(text: string, maxLen = 180): string { | |
| 184 | + | export function plaintextPreview(text: string, maxLen = PREVIEW_MAX_LENGTH): string { | |
| 185 | 185 | const firstBlock = text.split("\n\n")[0]?.trim() ?? ""; | |
| 186 | 186 | const firstLine = firstBlock.split("\n")[0] ?? ""; | |
| 187 | 187 | if (firstLine.length <= maxLen) return firstLine; | |
| 188 | 188 | const truncated = firstLine.slice(0, maxLen); | |
| 189 | 189 | const lastSpace = truncated.lastIndexOf(" "); | |
| 190 | 190 | return ( | |
| 191 | - | (lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) + | |
| 191 | + | (lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD ? truncated.slice(0, lastSpace) : truncated) + | |
| 192 | 192 | "…" | |
| 193 | 193 | ); | |
| 194 | 194 | } | |
Msrc/views/repos/BranchList.tsx
| @@ -2,6 +2,7 @@ import type { RepositoryRow } from "../../db/index.ts"; | |||
|---|---|---|---|
| 2 | 2 | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 3 | 3 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | 4 | import type { BranchInfo } from "../../services/git.ts"; | |
| 5 | + | import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts"; | |
| 5 | 6 | import { Layout } from "../layout.tsx"; | |
| 6 | 7 | import { Pagination } from "../Pagination.tsx"; | |
| 7 | 8 | import { RepoHeader } from "./RepoHeader.tsx"; | |
| @@ -56,7 +57,7 @@ export function BranchList({ | |||
|---|---|---|---|
| 56 | 57 | type="text" | |
| 57 | 58 | required | |
| 58 | 59 | placeholder="feature/my-branch" | |
| 59 | - | maxlength="255" | |
| 60 | + | maxlength={MAX_BRANCH_NAME_LENGTH} | |
| 60 | 61 | /> | |
| 61 | 62 | </div> | |
| 62 | 63 | <div class="form-group"> | |
| @@ -70,7 +71,7 @@ export function BranchList({ | |||
|---|---|---|---|
| 70 | 71 | required | |
| 71 | 72 | value={repo.default_branch} | |
| 72 | 73 | placeholder="branch, tag, or commit" | |
| 73 | - | maxlength="255" | |
| 74 | + | maxlength={MAX_BRANCH_NAME_LENGTH} | |
| 74 | 75 | /> | |
| 75 | 76 | </div> | |
| 76 | 77 | <button | |
| @@ -153,7 +154,7 @@ export function BranchList({ | |||
|---|---|---|---|
| 153 | 154 | required | |
| 154 | 155 | placeholder="new-name" | |
| 155 | 156 | value={b.name} | |
| 156 | - | maxlength="255" | |
| 157 | + | maxlength={MAX_BRANCH_NAME_LENGTH} | |
| 157 | 158 | /> | |
| 158 | 159 | <button | |
| 159 | 160 | type="submit" | |
Msrc/views/repos/FileEdit.tsx
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | 2 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { MAX_FILE_PATH_LENGTH } from "../../constants.ts"; | |
| 3 | 4 | import { Layout } from "../layout.tsx"; | |
| 4 | 5 | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 5 | 6 | import { RepoNav } from "./RepoNav.tsx"; | |
| @@ -102,7 +103,7 @@ export function FileEdit({ | |||
|---|---|---|---|
| 102 | 103 | type="text" | |
| 103 | 104 | value={filePath} | |
| 104 | 105 | class="mono" | |
| 105 | - | maxlength="1000" | |
| 106 | + | maxlength={MAX_FILE_PATH_LENGTH} | |
| 106 | 107 | /> | |
| 107 | 108 | </div> | |
| 108 | 109 | <div class="form-group"> | |
Msrc/views/repos/NewFileForm.tsx
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | 2 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { MAX_FILE_PATH_LENGTH } from "../../constants.ts"; | |
| 3 | 4 | import { Layout } from "../layout.tsx"; | |
| 4 | 5 | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 5 | 6 | import { RepoNav } from "./RepoNav.tsx"; | |
| @@ -70,7 +71,7 @@ export function NewFileForm({ | |||
|---|---|---|---|
| 70 | 71 | value={defaultPath} | |
| 71 | 72 | placeholder="path/to/file.txt" | |
| 72 | 73 | class="mono" | |
| 73 | - | maxlength="1000" | |
| 74 | + | maxlength={MAX_FILE_PATH_LENGTH} | |
| 74 | 75 | /> | |
| 75 | 76 | </div> | |
| 76 | 77 | <div class="form-group"> | |
Msrc/views/repos/TagList.tsx
| @@ -2,6 +2,7 @@ import type { RepositoryRow } from "../../db/index.ts"; | |||
|---|---|---|---|
| 2 | 2 | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 3 | 3 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | 4 | import type { TagInfo } from "../../services/git.ts"; | |
| 5 | + | import { MAX_TAG_MESSAGE_LENGTH, MAX_TAG_NAME_LENGTH } from "../../constants.ts"; | |
| 5 | 6 | import { Layout } from "../layout.tsx"; | |
| 6 | 7 | import { Pagination } from "../Pagination.tsx"; | |
| 7 | 8 | import { RepoHeader } from "./RepoHeader.tsx"; | |
| @@ -58,7 +59,7 @@ export function TagList({ | |||
|---|---|---|---|
| 58 | 59 | type="text" | |
| 59 | 60 | required | |
| 60 | 61 | placeholder="v1.0.0" | |
| 61 | - | maxlength="255" | |
| 62 | + | maxlength={MAX_TAG_NAME_LENGTH} | |
| 62 | 63 | /> | |
| 63 | 64 | </div> | |
| 64 | 65 | <div class="form-group"> | |
| @@ -70,7 +71,7 @@ export function TagList({ | |||
|---|---|---|---|
| 70 | 71 | required | |
| 71 | 72 | value={repo.default_branch} | |
| 72 | 73 | placeholder="branch, tag, or commit" | |
| 73 | - | maxlength="255" | |
| 74 | + | maxlength={MAX_TAG_NAME_LENGTH} | |
| 74 | 75 | /> | |
| 75 | 76 | </div> | |
| 76 | 77 | <div class="form-group"> | |
| @@ -85,7 +86,7 @@ export function TagList({ | |||
|---|---|---|---|
| 85 | 86 | name="message" | |
| 86 | 87 | type="text" | |
| 87 | 88 | placeholder="Optional tag message" | |
| 88 | - | maxlength="500" | |
| 89 | + | maxlength={MAX_TAG_MESSAGE_LENGTH} | |
| 89 | 90 | /> | |
| 90 | 91 | </div> | |
| 91 | 92 | <button | |