add per-user git identity for patch authorship
Users can now set their own git name and email in Settings, which are stored on the user record and used as the author when creating/merging patches. Removes the global GIT_AUTHOR_NAME/EMAIL config in favour of per-user identity. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
MREADME.md
| @@ -64,8 +64,6 @@ All settings are environment variables: | |||
|---|---|---|---|
| 64 | 64 | | `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` | | |
| 65 | 65 | | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting | | |
| 66 | 66 | | `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* | | |
| 67 | - | | `GIT_AUTHOR_NAME` | `OWNER_DISPLAY_NAME` | Author/committer name used when merging patches | | |
| 68 | - | | `GIT_AUTHOR_EMAIL` | `{name}@{BASE_URL host}`| Author/committer email used when merging patches| | |
| 69 | 67 | ||
| 70 | 68 | \* More workers mean more CPU cores can be used to parallelize highlighting of files. | |
| 71 | 69 | Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB. | |
Msrc/config.ts
| @@ -15,11 +15,6 @@ export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000; | |||
|---|---|---|---|
| 15 | 15 | export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222; | |
| 16 | 16 | export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED; | |
| 17 | 17 | export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; | |
| 18 | - | export const GIT_AUTHOR_NAME = | |
| 19 | - | process.env.GIT_AUTHOR_NAME ?? OWNER_DISPLAY_NAME; | |
| 20 | - | export const GIT_AUTHOR_EMAIL = | |
| 21 | - | process.env.GIT_AUTHOR_EMAIL ?? | |
| 22 | - | `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`; | |
| 23 | 18 | export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data"); | |
| 24 | 19 | export const HIGHLIGHT_WORKERS = | |
| 25 | 20 | parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4; | |
Msrc/db/index.ts
| @@ -10,6 +10,8 @@ interface UserTable { | |||
|---|---|---|---|
| 10 | 10 | password_hash: string | null; | |
| 11 | 11 | created_at: string; | |
| 12 | 12 | avatar_version: Generated<number>; | |
| 13 | + | git_name: string | null; | |
| 14 | + | git_email: string | null; | |
| 13 | 15 | } | |
| 14 | 16 | ||
| 15 | 17 | interface PasskeyTable { | |
| @@ -78,6 +80,8 @@ interface PatchTable { | |||
|---|---|---|---|
| 78 | 80 | description: string; | |
| 79 | 81 | patch_content: string; | |
| 80 | 82 | status: string; | |
| 83 | + | author_name: string; | |
| 84 | + | author_email: string; | |
| 81 | 85 | created_at: string; | |
| 82 | 86 | updated_at: string; | |
| 83 | 87 | edited_at: string | null; | |
| @@ -164,6 +168,21 @@ const sqlite = new BunDatabase(DB_PATH); | |||
|---|---|---|---|
| 164 | 168 | sqlite.run("PRAGMA journal_mode=WAL"); | |
| 165 | 169 | sqlite.run("PRAGMA foreign_keys=ON"); | |
| 166 | 170 | ||
| 171 | + | // Migrations: add new columns to existing tables if not present | |
| 172 | + | // SQLite does not support IF NOT EXISTS on ALTER TABLE, so we catch the error. | |
| 173 | + | for (const sql of [ | |
| 174 | + | "ALTER TABLE users ADD COLUMN git_name TEXT", | |
| 175 | + | "ALTER TABLE users ADD COLUMN git_email TEXT", | |
| 176 | + | "ALTER TABLE patches ADD COLUMN author_name TEXT NOT NULL DEFAULT ''", | |
| 177 | + | "ALTER TABLE patches ADD COLUMN author_email TEXT NOT NULL DEFAULT ''", | |
| 178 | + | ]) { | |
| 179 | + | try { | |
| 180 | + | sqlite.run(sql); | |
| 181 | + | } catch { | |
| 182 | + | // Column already exists — ignore | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 167 | 186 | export const db = new Kysely<Database>({ | |
| 168 | 187 | dialect: new BunSqliteDialect({ database: sqlite }), | |
| 169 | 188 | }); | |
Msrc/db/schema.sql
| @@ -3,7 +3,9 @@ CREATE TABLE IF NOT EXISTS users ( | |||
|---|---|---|---|
| 3 | 3 | username TEXT UNIQUE NOT NULL, | |
| 4 | 4 | password_hash TEXT, | |
| 5 | 5 | created_at TEXT NOT NULL, | |
| 6 | - | avatar_version INTEGER NOT NULL DEFAULT 1 | |
| 6 | + | avatar_version INTEGER NOT NULL DEFAULT 1, | |
| 7 | + | git_name TEXT, | |
| 8 | + | git_email TEXT | |
| 7 | 9 | ); | |
| 8 | 10 | ||
| 9 | 11 | CREATE TABLE IF NOT EXISTS passkeys ( | |
| @@ -74,6 +76,8 @@ CREATE TABLE IF NOT EXISTS patches ( | |||
|---|---|---|---|
| 74 | 76 | description TEXT NOT NULL DEFAULT '', | |
| 75 | 77 | patch_content TEXT NOT NULL, | |
| 76 | 78 | status TEXT NOT NULL DEFAULT 'open', | |
| 79 | + | author_name TEXT NOT NULL DEFAULT '', | |
| 80 | + | author_email TEXT NOT NULL DEFAULT '', | |
| 77 | 81 | created_at TEXT NOT NULL, | |
| 78 | 82 | updated_at TEXT NOT NULL, | |
| 79 | 83 | edited_at TEXT, | |
Msrc/middleware/session.ts
| @@ -6,6 +6,8 @@ export interface SessionUser { | |||
|---|---|---|---|
| 6 | 6 | username: string; | |
| 7 | 7 | isAdmin: boolean; | |
| 8 | 8 | avatar_version: number; | |
| 9 | + | git_name: string | null; | |
| 10 | + | git_email: string | null; | |
| 9 | 11 | } | |
| 10 | 12 | ||
| 11 | 13 | export async function resolveSession( | |
| @@ -20,6 +22,8 @@ export async function resolveSession( | |||
|---|---|---|---|
| 20 | 22 | "users.id", | |
| 21 | 23 | "users.username", | |
| 22 | 24 | "users.avatar_version", | |
| 25 | + | "users.git_name", | |
| 26 | + | "users.git_email", | |
| 23 | 27 | "sessions.expires_at", | |
| 24 | 28 | ]) | |
| 25 | 29 | .where("sessions.id", "=", cookie) | |
| @@ -31,6 +35,8 @@ export async function resolveSession( | |||
|---|---|---|---|
| 31 | 35 | username: session.username, | |
| 32 | 36 | isAdmin: session.username === ADMIN_USERNAME, | |
| 33 | 37 | avatar_version: session.avatar_version, | |
| 38 | + | git_name: session.git_name, | |
| 39 | + | git_email: session.git_email, | |
| 34 | 40 | }; | |
| 35 | 41 | } | |
| 36 | 42 | ||
Msrc/routes/patches.tsx
| @@ -90,6 +90,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 90 | 90 | "patches.description", | |
| 91 | 91 | "patches.patch_content", | |
| 92 | 92 | "patches.status", | |
| 93 | + | "patches.author_name", | |
| 94 | + | "patches.author_email", | |
| 93 | 95 | "patches.created_at", | |
| 94 | 96 | "patches.updated_at", | |
| 95 | 97 | "patches.edited_at", | |
| @@ -150,6 +152,16 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 150 | 152 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 151 | 153 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 152 | 154 | ||
| 155 | + | if (!user!.git_name?.trim() || !user!.git_email?.trim()) { | |
| 156 | + | return html( | |
| 157 | + | <NewPatch | |
| 158 | + | user={user!} | |
| 159 | + | repo={repo} | |
| 160 | + | error="You must set your git name and email in Settings before creating a patch" | |
| 161 | + | />, | |
| 162 | + | ); | |
| 163 | + | } | |
| 164 | + | ||
| 153 | 165 | if (!body.title?.trim()) { | |
| 154 | 166 | return html( | |
| 155 | 167 | <NewPatch | |
| @@ -222,6 +234,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 222 | 234 | description: body.description?.trim() ?? "", | |
| 223 | 235 | patch_content: patchContent, | |
| 224 | 236 | status: "open", | |
| 237 | + | author_name: user!.git_name!, | |
| 238 | + | author_email: user!.git_email!, | |
| 225 | 239 | created_at: now, | |
| 226 | 240 | updated_at: now, | |
| 227 | 241 | }) | |
| @@ -266,6 +280,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 266 | 280 | "patches.description", | |
| 267 | 281 | "patches.patch_content", | |
| 268 | 282 | "patches.status", | |
| 283 | + | "patches.author_name", | |
| 284 | + | "patches.author_email", | |
| 269 | 285 | "patches.created_at", | |
| 270 | 286 | "patches.updated_at", | |
| 271 | 287 | "patches.edited_at", | |
| @@ -345,6 +361,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 345 | 361 | patch as typeof patch & { | |
| 346 | 362 | author_username: string; | |
| 347 | 363 | author_avatar_version: number | null; | |
| 364 | + | author_name: string; | |
| 365 | + | author_email: string; | |
| 348 | 366 | } | |
| 349 | 367 | } | |
| 350 | 368 | descriptionHtml={descriptionHtml} | |
| @@ -371,13 +389,29 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 371 | 389 | const user = await resolveSession(cookie.session.value); | |
| 372 | 390 | const deny = requireAdmin(user); | |
| 373 | 391 | if (deny) return deny; | |
| 392 | + | ||
| 393 | + | if (!user!.git_name?.trim() || !user!.git_email?.trim()) { | |
| 394 | + | return new Response( | |
| 395 | + | "Set your git name and email in Settings before merging", | |
| 396 | + | { status: 400, headers: { "Content-Type": "text/plain" } }, | |
| 397 | + | ); | |
| 398 | + | } | |
| 399 | + | ||
| 374 | 400 | const repo = await getRepo(params.repo, true); | |
| 375 | 401 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 376 | 402 | ||
| 377 | 403 | const patchNum = parseInt(params.number, 10); | |
| 378 | 404 | const patch = await db | |
| 379 | 405 | .selectFrom("patches") | |
| 380 | - | .select(["id", "title", "description", "patch_content", "status"]) | |
| 406 | + | .select([ | |
| 407 | + | "id", | |
| 408 | + | "title", | |
| 409 | + | "description", | |
| 410 | + | "patch_content", | |
| 411 | + | "status", | |
| 412 | + | "author_name", | |
| 413 | + | "author_email", | |
| 414 | + | ]) | |
| 381 | 415 | .where("repo_id", "=", repo.id) | |
| 382 | 416 | .where("number", "=", patchNum) | |
| 383 | 417 | .executeTakeFirst(); | |
| @@ -400,6 +434,10 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 400 | 434 | patch.patch_content, | |
| 401 | 435 | patch.title, | |
| 402 | 436 | patch.description, | |
| 437 | + | patch.author_name, | |
| 438 | + | patch.author_email, | |
| 439 | + | user!.git_name!, | |
| 440 | + | user!.git_email!, | |
| 403 | 441 | ); | |
| 404 | 442 | } catch (err) { | |
| 405 | 443 | // Roll back the status if the git operation fails | |
Msrc/routes/settings.tsx
| @@ -34,7 +34,7 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 34 | 34 | ||
| 35 | 35 | const userRow = await db | |
| 36 | 36 | .selectFrom("users") | |
| 37 | - | .select(["id", "password_hash"]) | |
| 37 | + | .select(["id", "password_hash", "git_name", "git_email"]) | |
| 38 | 38 | .where("id", "=", user.id) | |
| 39 | 39 | .executeTakeFirst(); | |
| 40 | 40 | ||
| @@ -61,6 +61,8 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 61 | 61 | <Settings | |
| 62 | 62 | user={user} | |
| 63 | 63 | hasPassword={hasPassword} | |
| 64 | + | gitName={userRow?.git_name ?? null} | |
| 65 | + | gitEmail={userRow?.git_email ?? null} | |
| 64 | 66 | passkeys={passkeys} | |
| 65 | 67 | sshKeys={sshKeys} | |
| 66 | 68 | theme={theme} | |
| @@ -344,6 +346,40 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 344 | 346 | }, | |
| 345 | 347 | ) | |
| 346 | 348 | ||
| 349 | + | .post( | |
| 350 | + | "/settings/git-identity", | |
| 351 | + | async ({ cookie, body }) => { | |
| 352 | + | const user = await resolveSession( | |
| 353 | + | cookie.session?.value as string | undefined, | |
| 354 | + | ); | |
| 355 | + | if (!user) return redirect("/login"); | |
| 356 | + | ||
| 357 | + | const name = body.git_name.trim(); | |
| 358 | + | const email = body.git_email.trim(); | |
| 359 | + | ||
| 360 | + | if (!name) { | |
| 361 | + | return redirect("/settings?error=Git+name+is+required"); | |
| 362 | + | } | |
| 363 | + | if (!email) { | |
| 364 | + | return redirect("/settings?error=Git+email+is+required"); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | await db | |
| 368 | + | .updateTable("users") | |
| 369 | + | .set({ git_name: name, git_email: email }) | |
| 370 | + | .where("id", "=", user.id) | |
| 371 | + | .execute(); | |
| 372 | + | ||
| 373 | + | return redirect("/settings?success=git_identity"); | |
| 374 | + | }, | |
| 375 | + | { | |
| 376 | + | body: t.Object({ | |
| 377 | + | git_name: t.String(), | |
| 378 | + | git_email: t.String(), | |
| 379 | + | }), | |
| 380 | + | }, | |
| 381 | + | ) | |
| 382 | + | ||
| 347 | 383 | .post( | |
| 348 | 384 | "/settings/ssh-keys", | |
| 349 | 385 | async ({ cookie, body }) => { | |
Msrc/services/git.ts
| @@ -1,7 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | import { $ as _$ } from "bun"; | |
| 3 | 3 | ||
| 4 | - | import { GIT_AUTHOR_EMAIL, GIT_AUTHOR_NAME } from "../config.ts"; | |
| 5 | 4 | import { REPOS_DIR } from "../constants.ts"; | |
| 6 | 5 | ||
| 7 | 6 | const $ = _$.env({ ...process.env, LC_ALL: "C", LANG: "C" }); | |
| @@ -360,7 +359,11 @@ export const git = { | |||
|---|---|---|---|
| 360 | 359 | name: string, | |
| 361 | 360 | patchContent: string, | |
| 362 | 361 | title: string, | |
| 363 | - | description?: string, | |
| 362 | + | description: string, | |
| 363 | + | authorName: string, | |
| 364 | + | authorEmail: string, | |
| 365 | + | committerName: string, | |
| 366 | + | committerEmail: string, | |
| 364 | 367 | ): Promise<void> { | |
| 365 | 368 | return withRepoLock(name, async () => { | |
| 366 | 369 | const p = repoPath(name); | |
| @@ -374,7 +377,7 @@ export const git = { | |||
|---|---|---|---|
| 374 | 377 | const parent = ( | |
| 375 | 378 | await $`git -C ${p} rev-parse HEAD`.text() | |
| 376 | 379 | ).trim(); | |
| 377 | - | const fallback = description?.trim() | |
| 380 | + | const fallback = description.trim() | |
| 378 | 381 | ? `${title}\n\n${description.trim()}` | |
| 379 | 382 | : title; | |
| 380 | 383 | const msg = extractPatchSubject(patchContent) || fallback; | |
| @@ -384,10 +387,10 @@ export const git = { | |||
|---|---|---|---|
| 384 | 387 | ...process.env, | |
| 385 | 388 | LC_ALL: "C", | |
| 386 | 389 | LANG: "C", | |
| 387 | - | GIT_AUTHOR_NAME, | |
| 388 | - | GIT_AUTHOR_EMAIL, | |
| 389 | - | GIT_COMMITTER_NAME: GIT_AUTHOR_NAME, | |
| 390 | - | GIT_COMMITTER_EMAIL: GIT_AUTHOR_EMAIL, | |
| 390 | + | GIT_AUTHOR_NAME: authorName, | |
| 391 | + | GIT_AUTHOR_EMAIL: authorEmail, | |
| 392 | + | GIT_COMMITTER_NAME: committerName, | |
| 393 | + | GIT_COMMITTER_EMAIL: committerEmail, | |
| 391 | 394 | }) | |
| 392 | 395 | .text() | |
| 393 | 396 | ).trim(); | |
Msrc/styles/main.css
| @@ -1566,6 +1566,20 @@ | |||
|---|---|---|---|
| 1566 | 1566 | display: flex; | |
| 1567 | 1567 | gap: var(--space-2); | |
| 1568 | 1568 | } | |
| 1569 | + | .patch-author-meta { | |
| 1570 | + | display: flex; | |
| 1571 | + | align-items: center; | |
| 1572 | + | gap: var(--space-2); | |
| 1573 | + | font-size: var(--text-xs); | |
| 1574 | + | margin-top: calc(-1 * var(--space-4)); | |
| 1575 | + | margin-bottom: var(--space-6); | |
| 1576 | + | } | |
| 1577 | + | .patch-author-label { | |
| 1578 | + | color: var(--color-text-muted); | |
| 1579 | + | } | |
| 1580 | + | .patch-author-identity { | |
| 1581 | + | font-family: monospace; | |
| 1582 | + | } | |
| 1569 | 1583 | .timeline-item { | |
| 1570 | 1584 | border: 1px solid var(--color-border); | |
| 1571 | 1585 | border-radius: var(--radius-lg); | |
Msrc/views/Settings.tsx
| @@ -5,6 +5,8 @@ import { Layout } from "./layout.tsx"; | |||
|---|---|---|---|
| 5 | 5 | interface SettingsProps { | |
| 6 | 6 | user: SessionUser; | |
| 7 | 7 | hasPassword: boolean; | |
| 8 | + | gitName: string | null; | |
| 9 | + | gitEmail: string | null; | |
| 8 | 10 | passkeys: { id: number; created_at: string }[]; | |
| 9 | 11 | sshKeys: { | |
| 10 | 12 | id: number; | |
| @@ -26,11 +28,14 @@ const successMessages: Record<string, string> = { | |||
|---|---|---|---|
| 26 | 28 | user_deleted: "Account deleted.", | |
| 27 | 29 | ssh_key_added: "SSH key added.", | |
| 28 | 30 | ssh_key_deleted: "SSH key removed.", | |
| 31 | + | git_identity: "Git identity saved.", | |
| 29 | 32 | }; | |
| 30 | 33 | ||
| 31 | 34 | export function Settings({ | |
| 32 | 35 | user, | |
| 33 | 36 | hasPassword, | |
| 37 | + | gitName, | |
| 38 | + | gitEmail, | |
| 34 | 39 | passkeys, | |
| 35 | 40 | sshKeys, | |
| 36 | 41 | theme, | |
| @@ -96,6 +101,55 @@ export function Settings({ | |||
|---|---|---|---|
| 96 | 101 | </div> | |
| 97 | 102 | </div> | |
| 98 | 103 | ||
| 104 | + | {/* Git Identity */} | |
| 105 | + | <div class="form-card"> | |
| 106 | + | <h2 class="section-title">Git Identity</h2> | |
| 107 | + | <p class="text-muted"> | |
| 108 | + | Used as the author when creating patches. Required to | |
| 109 | + | submit patches. | |
| 110 | + | </p> | |
| 111 | + | <form | |
| 112 | + | method="POST" | |
| 113 | + | action="/settings/git-identity" | |
| 114 | + | class="settings-form" | |
| 115 | + | style="margin-top: var(--space-4)" | |
| 116 | + | > | |
| 117 | + | <div class="form-group"> | |
| 118 | + | <label class="form-label" for="git_name"> | |
| 119 | + | Name | |
| 120 | + | </label> | |
| 121 | + | <input | |
| 122 | + | class="form-input" | |
| 123 | + | type="text" | |
| 124 | + | id="git_name" | |
| 125 | + | name="git_name" | |
| 126 | + | value={gitName ?? ""} | |
| 127 | + | autocomplete="name" | |
| 128 | + | required | |
| 129 | + | /> | |
| 130 | + | </div> | |
| 131 | + | <div class="form-group"> | |
| 132 | + | <label class="form-label" for="git_email"> | |
| 133 | + | ||
| 134 | + | </label> | |
| 135 | + | <input | |
| 136 | + | class="form-input" | |
| 137 | + | type="email" | |
| 138 | + | id="git_email" | |
| 139 | + | name="git_email" | |
| 140 | + | value={gitEmail ?? ""} | |
| 141 | + | autocomplete="email" | |
| 142 | + | required | |
| 143 | + | /> | |
| 144 | + | </div> | |
| 145 | + | <div class="form-actions"> | |
| 146 | + | <button class="btn btn-primary" type="submit"> | |
| 147 | + | Save | |
| 148 | + | </button> | |
| 149 | + | </div> | |
| 150 | + | </form> | |
| 151 | + | </div> | |
| 152 | + | ||
| 99 | 153 | {/* Appearance */} | |
| 100 | 154 | <div class="form-card"> | |
| 101 | 155 | <h2 class="section-title">Appearance</h2> | |
Msrc/views/patches/PatchDetail.tsx
| @@ -1,3 +1,4 @@ | |||
|---|---|---|---|
| 1 | + | import { escapeHtml } from "@kitajs/html"; | |
| 1 | 2 | import type { | |
| 2 | 3 | PatchCommentRow, | |
| 3 | 4 | PatchRow, | |
| @@ -21,6 +22,8 @@ interface PatchDetailProps { | |||
|---|---|---|---|
| 21 | 22 | patch: PatchRow & { | |
| 22 | 23 | author_username: string; | |
| 23 | 24 | author_avatar_version: number | null; | |
| 25 | + | author_name: string; | |
| 26 | + | author_email: string; | |
| 24 | 27 | }; | |
| 25 | 28 | descriptionHtml: string; | |
| 26 | 29 | applyResult: ApplyResult | null; | |
| @@ -173,6 +176,10 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 173 | 176 | </div> | |
| 174 | 177 | )} | |
| 175 | 178 | </div> | |
| 179 | + | <div class="patch-author-meta"> | |
| 180 | + | <span class="patch-author-label">git author:</span> | |
| 181 | + | <span class="patch-author-identity">{escapeHtml(`${patch.author_name} <${patch.author_email}>`)}</span> | |
| 182 | + | </div> | |
| 176 | 183 | </div> | |
| 177 | 184 | ||
| 178 | 185 | {/* Subview tabs */} | |