extract constants
Msrc/constants.ts
@@ -26,6 +26,38 @@ export const VALID_KEY_TYPES = new Set([
]);
export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Rate limiting
export const LOGIN_MAX_ATTEMPTS = 10;
export const LOGIN_RATE_WINDOW_MS = 60_000;
export const REGISTRATION_MAX_ATTEMPTS = 3;
export const REGISTRATION_RATE_WINDOW_MS = 60 * 60_000;
// Session
export const SESSION_ID_BYTES = 32;
export const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
export const SESSION_DURATION_SECONDS = 30 * 24 * 60 * 60;
export const MIN_PASSWORD_LENGTH = 8;
// Cookie lifetimes
export const YEAR_SECONDS = 365 * 24 * 60 * 60;
// File handling
export const BINARY_DETECT_BYTES = 8000;
// Git ref limits
export const MAX_REF_LIST = 1000;
// Text preview
export const PREVIEW_MAX_LENGTH = 180;
export const PREVIEW_TRUNCATION_THRESHOLD = 0.6;
// String length limits
export const MAX_BRANCH_NAME_LENGTH = 255;
export const MAX_TAG_NAME_LENGTH = 255;
export const MAX_TAG_MESSAGE_LENGTH = 500;
export const MAX_LABEL_NAME_LENGTH = 50;
export const MAX_FILE_PATH_LENGTH = 1000;
// Pagination
export const REPOS_PER_PAGE = 20;
export const COMMITS_PER_PAGE = 20;
Msrc/routes/auth.tsx
@@ -10,6 +10,14 @@ import config from "../config.ts";
import {
ADMIN_USERNAME,
CHALLENGE_TTL_MS,
LOGIN_MAX_ATTEMPTS,
LOGIN_RATE_WINDOW_MS,
MIN_PASSWORD_LENGTH,
REGISTRATION_MAX_ATTEMPTS,
REGISTRATION_RATE_WINDOW_MS,
SESSION_DURATION_MS,
SESSION_DURATION_SECONDS,
SESSION_ID_BYTES,
VALID_USERNAME_RE,
WEBAUTHN_RP_NAME,
} from "../constants.ts";
@@ -36,9 +44,9 @@ function randomHex(bytes: number): string {
}
async function createSession(userId: number): Promise<string> {
const id = randomHex(32);
const id = randomHex(SESSION_ID_BYTES);
const now = new Date();
const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
const expires = new Date(now.getTime() + SESSION_DURATION_MS);
await db
.insertInto("sessions")
.values({
@@ -52,7 +60,7 @@ async function createSession(userId: number): Promise<string> {
}
function sessionCookie(id: string): string {
return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`;
return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DURATION_SECONDS}`;
}
function clearCookie(): string {
@@ -94,7 +102,7 @@ export const authRoutes = new Elysia()
"/login",
async ({ body, request, server }) => {
const ip = getClientIp(request, server);
if (!checkRateLimit(ip, 10, 60_000)) {
if (!checkRateLimit(ip, LOGIN_MAX_ATTEMPTS, LOGIN_RATE_WINDOW_MS)) {
return html(
<Login error="Too many login attempts. Please try again later." />,
);
@@ -143,7 +151,7 @@ export const authRoutes = new Elysia()
status: 403,
});
const ip = getClientIp(request, server);
if (!checkRateLimit(ip, 3, 60 * 60_000)) {
if (!checkRateLimit(ip, REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS)) {
return html(
<Register
error="Too many registration attempts. Please try again later."
@@ -186,7 +194,7 @@ export const authRoutes = new Elysia()
/>,
);
}
if (password.length < 8) {
if (password.length < MIN_PASSWORD_LENGTH) {
return html(
<Register
error="Password must be at least 8 characters"
Msrc/routes/avatars.ts
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia";
import config from "../config.ts";
import { YEAR_SECONDS } from "../constants.ts";
import { db } from "../db/index.ts";
import { requireAuth, resolveSession } from "../middleware/session.ts";
import {
@@ -8,7 +9,7 @@ import {
processAndStoreAvatar,
} from "../services/avatar.ts";
const CACHE = "public, max-age=31536000, immutable";
const CACHE = `public, max-age=${YEAR_SECONDS}, immutable`;
async function bumpAvatarVersion(userId: number): Promise<void> {
await db
Msrc/routes/repos.tsx
@@ -5,12 +5,16 @@ import { fileTypeFromBuffer } from "file-type";
import { sql } from "kysely";
import config from "../config.ts";
import {
BINARY_DETECT_BYTES,
BRANCHES_PER_PAGE,
COMMITS_PER_PAGE,
MAX_BRANCH_NAME_LENGTH,
MAX_LABEL_NAME_LENGTH,
paths,
REPOS_PER_PAGE,
TAGS_PER_PAGE,
VALID_REPO_NAME_RE,
YEAR_SECONDS,
} from "../constants.ts";
import { db } from "../db/index.ts";
import { redirect } from "../lib/redirect.ts";
@@ -56,7 +60,7 @@ async function mimeForContent(
return typeFromName;
}
return hasBinaryContent(content.subarray(0, 8000))
return hasBinaryContent(content.subarray(0, BINARY_DETECT_BYTES))
? "application/octet-stream"
: "text/plain; charset=utf-8";
}
@@ -97,7 +101,7 @@ export const repoRoutes = new Elysia()
const sort = body.sort === "name" ? "name" : "created";
return redirect(
"/",
`repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`,
`repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`,
);
},
{ body: t.Object({ sort: t.String() }) },
@@ -864,7 +868,7 @@ export const repoRoutes = new Elysia()
const name = body.name?.trim();
const color = body.color?.trim();
if (!name || name.length > 50) {
if (!name || name.length > MAX_LABEL_NAME_LENGTH) {
return redirect(
`/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
);
@@ -980,7 +984,7 @@ export const repoRoutes = new Elysia()
!name ||
!/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
name.includes("..") ||
name.length > 255
name.length > MAX_BRANCH_NAME_LENGTH
) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
@@ -1077,7 +1081,7 @@ export const repoRoutes = new Elysia()
!newName ||
!/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
newName.includes("..") ||
newName.length > 255
newName.length > MAX_BRANCH_NAME_LENGTH
) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
Msrc/routes/settings.tsx
@@ -6,6 +6,7 @@ import {
ADMIN_USERNAME,
VALID_KEY_TYPES,
VALID_USERNAME_RE,
YEAR_SECONDS,
} from "../constants.ts";
import { db } from "../db";
import { redirect } from "../lib/redirect.ts";
@@ -267,7 +268,7 @@ export const settingsRoutes = new Elysia()
return redirect("/settings?error=Invalid+theme");
}
const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`;
const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`;
return redirect("/settings?success=theme", cookieHeader);
},
{
Msrc/services/git.ts
@@ -3,6 +3,7 @@ import { $ as _$ } from "bun";
import {
MAX_BRANCH_CACHE,
MAX_REF_LIST,
MAX_TAG_CACHE,
paths,
REF_CACHE_TTL_MS,
@@ -420,7 +421,7 @@ export const git = {
async branchesWithInfo(
name: string,
maxCount = 1000,
maxCount = MAX_REF_LIST,
): Promise<BranchInfo[]> {
const p = repoPath(name);
try {
Msrc/services/highlight.ts
@@ -8,7 +8,7 @@ import {
type Highlighter,
} from "shiki";
import config from "../config.ts";
import { MAX_FILE_CACHE } from "../constants.ts";
import { BINARY_DETECT_BYTES, MAX_FILE_CACHE } from "../constants.ts";
let highlighter: Highlighter | null = null;
let extToLangId: Map<string, string> | null = null;
@@ -72,7 +72,7 @@ export function getHighlighter(): Highlighter {
}
export function hasBinaryContent(buf: Buffer): boolean {
return buf.subarray(0, 8000).includes(0);
return buf.subarray(0, BINARY_DETECT_BYTES).includes(0);
}
export function detectLang(filename: string): string {
Msrc/services/markdown.ts
@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { Marked, marked, type Tokens } from "marked";
import { MAX_MD_CACHE } from "../constants.ts";
import { MAX_MD_CACHE, PREVIEW_MAX_LENGTH, PREVIEW_TRUNCATION_THRESHOLD } from "../constants.ts";
marked.setOptions({ gfm: true });
@@ -181,14 +181,14 @@ export function markdownToPlaintext(md: string): string {
* Returns a short single-line preview of a plaintext string:
* the first paragraph/heading line, truncated to maxLen chars.
*/
export function plaintextPreview(text: string, maxLen = 180): string {
export function plaintextPreview(text: string, maxLen = PREVIEW_MAX_LENGTH): string {
const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
const firstLine = firstBlock.split("\n")[0] ?? "";
if (firstLine.length <= maxLen) return firstLine;
const truncated = firstLine.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(" ");
return (
(lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) +
(lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD ? truncated.slice(0, lastSpace) : truncated) +
"…"
);
}
Msrc/views/repos/BranchList.tsx
@@ -2,6 +2,7 @@ import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { BranchInfo } from "../../services/git.ts";
import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { Pagination } from "../Pagination.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
@@ -56,7 +57,7 @@ export function BranchList({
type="text"
required
placeholder="feature/my-branch"
maxlength="255"
maxlength={MAX_BRANCH_NAME_LENGTH}
/>
</div>
<div class="form-group">
@@ -70,7 +71,7 @@ export function BranchList({
required
value={repo.default_branch}
placeholder="branch, tag, or commit"
maxlength="255"
maxlength={MAX_BRANCH_NAME_LENGTH}
/>
</div>
<button
@@ -153,7 +154,7 @@ export function BranchList({
required
placeholder="new-name"
value={b.name}
maxlength="255"
maxlength={MAX_BRANCH_NAME_LENGTH}
/>
<button
type="submit"
Msrc/views/repos/FileEdit.tsx
@@ -1,5 +1,6 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
@@ -102,7 +103,7 @@ export function FileEdit({
type="text"
value={filePath}
class="mono"
maxlength="1000"
maxlength={MAX_FILE_PATH_LENGTH}
/>
</div>
<div class="form-group">
Msrc/views/repos/NewFileForm.tsx
@@ -1,5 +1,6 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
@@ -70,7 +71,7 @@ export function NewFileForm({
value={defaultPath}
placeholder="path/to/file.txt"
class="mono"
maxlength="1000"
maxlength={MAX_FILE_PATH_LENGTH}
/>
</div>
<div class="form-group">
Msrc/views/repos/TagList.tsx
@@ -2,6 +2,7 @@ import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { TagInfo } from "../../services/git.ts";
import { MAX_TAG_MESSAGE_LENGTH, MAX_TAG_NAME_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { Pagination } from "../Pagination.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
@@ -58,7 +59,7 @@ export function TagList({
type="text"
required
placeholder="v1.0.0"
maxlength="255"
maxlength={MAX_TAG_NAME_LENGTH}
/>
</div>
<div class="form-group">
@@ -70,7 +71,7 @@ export function TagList({
required
value={repo.default_branch}
placeholder="branch, tag, or commit"
maxlength="255"
maxlength={MAX_TAG_NAME_LENGTH}
/>
</div>
<div class="form-group">
@@ -85,7 +86,7 @@ export function TagList({
name="message"
type="text"
placeholder="Optional tag message"
maxlength="500"
maxlength={MAX_TAG_MESSAGE_LENGTH}
/>
</div>
<button