add registration queue
MREADME.md
@@ -6,15 +6,15 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g
## Features
- **Repository browser** — file tree, directly edit single files, blob view, commit log, README rendering, media previews
- **Repository browser** — file tree, directly edit single files, blob view, commit log, markdown rendering, media previews
- **Issues** — create, comment, react
- **Patches** — submit git .patch files for review & comments. Admin can merge applicable patches directly into the repository.
- **Templates** — issue/patch templates
- **Releases** — releases with source archives, extra uploaded assets, and optional tag creation
- **SSH push/pull** — built-in SSH server, no external git daemon needed
- **Auth** — password login or passkeys (WebAuthn/FIDO2)
- **Commit signing** — merged patches automatically signed and verification badges are shown in the commit list view
- **Optional registration** — others can create accounts to file issues and patches; can be disabled
- **Commit signing** — merged patches and filed edited through the UI are automatically signed and verification badges are shown in the commit list view
- **Optional registration** — others can create accounts to file issues and patches; can be disabled, or put in queue mode where the admin manually approves new accounts.
## Stack
@@ -51,24 +51,25 @@ podman compose up
All settings are environment variables:
| Variable | Default | Description |
|-------------------------|----------------------------------|-------------------------------------------------|
| `PORT` | `3000` | HTTP port |
| `SSH_PORT` | `2222` | SSH port |
| `DATA_DIR` | `./data` | Repos, database, uploads |
| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches |
| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | Git committer email used when merging patches |
| Variable | Default | Description |
|-------------------------|----------------------------------|--------------------------------------------------------------------------------|
| `PORT` | `3000` | HTTP port |
| `SSH_PORT` | `2222` | SSH port |
| `DATA_DIR` | `./data` | Repos, database, uploads |
| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
| `REGISTRATION_TYPE` | `enabled` | Registration mode: `enabled`, `disabled`, or `queue` (requires admin approval) |
| `REGISTER_QUESTION` | _(empty)_ | Question shown on the registration form when `REGISTRATION_TYPE=queue` |
| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches or editing files through the UI |
| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | same as above but for email |
\* More workers mean more CPU cores can be used to parallelize highlighting of files.
Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
@@ -89,11 +90,10 @@ Pushing is also supported for the admin.
bun run dev # watch mode
bun run lint # Biome lint
bun run format # Biome format
bun run test # Run E2E and unit tests (don't use bun test, it doesn't respect the timeout)
bun run test # Run E2E and unit tests (don't use bun test directly, it doesn't respect the timeout)
```
## Roadmap
- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
- Issue labels
- redirect image urls in readme
- registration queue
- more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
Mcompose.yml
@@ -17,4 +17,5 @@ services:
# SSH_DISABLED: 0
# ARCHIVE_ZST_ENABLED: 0
# TRUSTED_PROXY: 1
# REGISTRATION_DISABLED: 0
# REGISTRATION_TYPE: enabled
# REGISTER_QUESTION: ""
Msrc/config.ts
@@ -13,7 +13,11 @@ export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED;
export const SSH_DISABLED = !!process.env.SSH_DISABLED;
export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED;
export const REGISTRATION_TYPE = (process.env.REGISTRATION_TYPE ?? "enabled") as
| "enabled"
| "disabled"
| "queue";
export const REGISTER_QUESTION = process.env.REGISTER_QUESTION ?? "";
export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
export const HIGHLIGHT_WORKERS =
Msrc/db/index.ts
@@ -10,6 +10,8 @@ interface UserTable {
password_hash: string | null;
created_at: string;
avatar_version: Generated<number>;
is_pending: Generated<number>;
register_application: string | null;
}
interface PasskeyTable {
@@ -169,6 +171,19 @@ const sqlite = new BunDatabase(DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
// Migration: add is_pending and register_application columns to users if missing
const userCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(users)")
.all();
if (!userCols.some((c) => c.name === "is_pending")) {
sqlite.run(
"ALTER TABLE users ADD COLUMN is_pending INTEGER NOT NULL DEFAULT 0",
);
}
if (!userCols.some((c) => c.name === "register_application")) {
sqlite.run("ALTER TABLE users ADD COLUMN register_application TEXT");
}
// Migration: add version column if missing, then populate any empty values
const patchCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(patches)")
Msrc/db/schema.sql
@@ -1,11 +1,13 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT,
created_at TEXT NOT NULL,
avatar_version INTEGER NOT NULL DEFAULT 1,
git_name TEXT,
git_email TEXT
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT,
created_at TEXT NOT NULL,
avatar_version INTEGER NOT NULL DEFAULT 1,
git_name TEXT,
git_email TEXT,
is_pending INTEGER NOT NULL DEFAULT 0,
register_application TEXT
);
CREATE TABLE IF NOT EXISTS passkeys (
Msrc/middleware/session.ts
@@ -24,6 +24,7 @@ export async function resolveSession(
])
.where("sessions.id", "=", cookie)
.where("sessions.expires_at", ">", now)
.where("users.is_pending", "=", 0)
.executeTakeFirst();
if (!session) return null;
return {
Msrc/routes/auth.tsx
@@ -6,7 +6,7 @@ import {
} from "@simplewebauthn/server";
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { REGISTRATION_DISABLED } from "../config.ts";
import { REGISTER_QUESTION, REGISTRATION_TYPE } from "../config.ts";
import {
ADMIN_USERNAME,
CHALLENGE_TTL_MS,
@@ -115,6 +115,12 @@ export const authRoutes = new Elysia()
return html(<Login error="Invalid username or password" />);
}
if (user.is_pending) {
return html(
<Login error="Your account is awaiting approval." />,
);
}
const sessionId = await createSession(user.id);
return redirect("/", sessionCookie(sessionId));
},
@@ -124,51 +130,74 @@ export const authRoutes = new Elysia()
)
.get("/register", () => {
if (REGISTRATION_DISABLED)
if (REGISTRATION_TYPE === "disabled")
return new Response("Registration is disabled", { status: 403 });
return html(<Register />);
return html(<Register question={REGISTER_QUESTION} />);
})
.post(
"/register",
async ({ body, request, server }) => {
if (REGISTRATION_DISABLED)
if (REGISTRATION_TYPE === "disabled")
return new Response("Registration is disabled", {
status: 403,
});
const ip = getClientIp(request, server);
if (!checkRateLimit(ip, 3, 60 * 60_000)) {
return html(
<Register error="Too many registration attempts. Please try again later." />,
<Register
error="Too many registration attempts. Please try again later."
question={REGISTER_QUESTION}
/>,
);
}
const { username, password, password2 } = body;
const { username, password, password2, application } = body;
if (!VALID_USERNAME_RE.test(username)) {
return html(
<Register error="Username may only contain letters, numbers, hyphens, and underscores" />,
<Register
error="Username may only contain letters, numbers, hyphens, and underscores"
question={REGISTER_QUESTION}
/>,
);
}
if (username === ADMIN_USERNAME) {
return html(<Register error="That username is reserved" />);
return html(
<Register
error="That username is reserved"
question={REGISTER_QUESTION}
/>,
);
}
if (!password?.trim()) {
return html(
<Register error="Password is required (use the passkey button for passwordless registration)" />,
<Register
error="Password is required (use the passkey button for passwordless registration)"
question={REGISTER_QUESTION}
/>,
);
}
if (password !== password2) {
return html(<Register error="Passwords do not match" />);
return html(
<Register
error="Passwords do not match"
question={REGISTER_QUESTION}
/>,
);
}
if (password.length < 8) {
return html(
<Register error="Password must be at least 8 characters" />,
<Register
error="Password must be at least 8 characters"
question={REGISTER_QUESTION}
/>,
);
}
const hash = await argon2.hash(password);
const now = new Date().toISOString();
const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
let result: { id: number };
try {
result = await db
@@ -177,6 +206,8 @@ export const authRoutes = new Elysia()
username,
password_hash: hash,
created_at: now,
is_pending: isPending,
register_application: application ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -187,11 +218,22 @@ export const authRoutes = new Elysia()
"UNIQUE constraint failed: users.username",
)
) {
return html(<Register error="Username already taken" />);
return html(
<Register
error="Username already taken"
question={REGISTER_QUESTION}
/>,
);
}
throw err;
}
if (REGISTRATION_TYPE === "queue") {
return html(
<Register pending={true} question={REGISTER_QUESTION} />,
);
}
const sessionId = await createSession(result.id);
return redirect("/", sessionCookie(sessionId));
},
@@ -200,6 +242,7 @@ export const authRoutes = new Elysia()
username: t.String(),
password: t.Optional(t.String()),
password2: t.Optional(t.String()),
application: t.Optional(t.String()),
}),
},
)
@@ -219,7 +262,13 @@ export const authRoutes = new Elysia()
.post(
"/auth/passkey/create-user",
async ({ body }) => {
const { username } = body;
if (REGISTRATION_TYPE === "disabled") {
return new Response(
JSON.stringify({ error: "Registration is disabled" }),
{ status: 400 },
);
}
const { username, application } = body;
if (!username || !VALID_USERNAME_RE.test(username)) {
return new Response(
JSON.stringify({ error: "Invalid username" }),
@@ -235,6 +284,7 @@ export const authRoutes = new Elysia()
);
}
const now = new Date().toISOString();
const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
let result: { id: number };
try {
result = await db
@@ -243,6 +293,8 @@ export const authRoutes = new Elysia()
username,
password_hash: null,
created_at: now,
is_pending: isPending,
register_application: application ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -261,6 +313,13 @@ export const authRoutes = new Elysia()
throw err;
}
if (REGISTRATION_TYPE === "queue") {
return new Response(
JSON.stringify({ ok: true, pending: true }),
{ headers: { "Content-Type": "application/json" } },
);
}
const sessionId = await createSession(result.id);
return new Response(JSON.stringify({ ok: true }), {
headers: {
@@ -270,7 +329,10 @@ export const authRoutes = new Elysia()
});
},
{
body: t.Object({ username: t.String() }),
body: t.Object({
username: t.String(),
application: t.Optional(t.String()),
}),
},
)
Msrc/routes/settings.tsx
@@ -6,6 +6,7 @@ import {
VALID_KEY_TYPES,
VALID_USERNAME_RE,
} from "../constants.ts";
import { REGISTRATION_TYPE } from "../config.ts";
import { db } from "../db";
import { redirect } from "../lib/redirect.ts";
import { resolveSession } from "../middleware/session.ts";
@@ -53,6 +54,21 @@ export const settingsRoutes = new Elysia()
const theme = (cookie.theme?.value as string | undefined) ?? "auto";
const hasPassword = !!userRow?.password_hash;
const pendingUsers =
user.isAdmin && REGISTRATION_TYPE === "queue"
? await db
.selectFrom("users")
.select([
"id",
"username",
"register_application",
"created_at",
])
.where("is_pending", "=", 1)
.orderBy("created_at", "desc")
.execute()
: [];
const decodedError = error
? decodeURIComponent((error as string).replace(/\+/g, " "))
: null;
@@ -66,6 +82,7 @@ export const settingsRoutes = new Elysia()
theme={theme}
success={(success as string | null) ?? null}
error={decodedError}
pendingUsers={pendingUsers}
/>,
);
},
@@ -344,6 +361,77 @@ export const settingsRoutes = new Elysia()
},
)
.post(
"/admin/users/approve",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
if (!user.isAdmin)
return new Response("Forbidden", { status: 403 });
await db
.updateTable("users")
.set({ is_pending: 0 })
.where("id", "=", body.id)
.where("is_pending", "=", 1)
.execute();
return redirect("/settings?success=user_approved");
},
{ body: t.Object({ id: t.Numeric() }) },
)
.post(
"/admin/users/deny",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
if (!user.isAdmin)
return new Response("Forbidden", { status: 403 });
await db
.deleteFrom("users")
.where("id", "=", body.id)
.where("is_pending", "=", 1)
.execute();
return redirect("/settings?success=user_denied");
},
{ body: t.Object({ id: t.Numeric() }) },
)
.post("/admin/users/approve-all", async ({ cookie }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
await db
.updateTable("users")
.set({ is_pending: 0 })
.where("is_pending", "=", 1)
.execute();
return redirect("/settings?success=all_approved");
})
.post("/admin/users/deny-all", async ({ cookie }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
await db.deleteFrom("users").where("is_pending", "=", 1).execute();
return redirect("/settings?success=all_denied");
})
.post(
"/settings/ssh-keys",
async ({ cookie, body }) => {
Msrc/styles/main.css
@@ -2574,6 +2574,54 @@
flex: 1;
color: var(--color-text-muted);
}
.queue-bulk-actions {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.queue-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 32rem;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.queue-item-meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.queue-item-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.queue-item-date {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.queue-item-answer {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: pre-wrap;
word-break: break-word;
}
.queue-item-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.ssh-key-info {
flex: 1;
display: flex;
Msrc/views/Settings.tsx
@@ -1,4 +1,4 @@
import { formatDate } from "../lib/formatDate.ts";
import { formatDate, formatDateTime } from "../lib/formatDate.ts";
import type { SessionUser } from "../middleware/session.ts";
import { Avatar } from "./Avatar.tsx";
import { Layout } from "./layout.tsx";
@@ -16,6 +16,12 @@ interface SettingsProps {
theme: string;
success: string | null;
error: string | null;
pendingUsers: {
id: number;
username: string;
register_application: string | null;
created_at: string;
}[];
}
const successMessages: Record<string, string> = {
@@ -25,6 +31,10 @@ const successMessages: Record<string, string> = {
theme: "Theme preference saved.",
user_created: "Account created.",
user_deleted: "Account deleted.",
user_approved: "Account approved.",
user_denied: "Account denied.",
all_approved: "All pending accounts approved.",
all_denied: "All pending accounts denied.",
ssh_key_added: "SSH key added.",
ssh_key_deleted: "SSH key removed.",
};
@@ -37,6 +47,7 @@ export function Settings({
theme,
success,
error,
pendingUsers,
}: SettingsProps) {
const successMsg = success ? (successMessages[success] ?? null) : null;
@@ -355,6 +366,100 @@ export function Settings({
</form>
</div>
{/* Admin: Registration Queue */}
{user.isAdmin && pendingUsers !== undefined && (
<div class="form-card">
<h2 class="section-title">Registration queue</h2>
{pendingUsers.length === 0 ? (
<p style="font-size: var(--text-sm); color: var(--color-text-muted); margin: 0">
No pending registrations.
</p>
) : (
<div>
<div class="queue-bulk-actions">
<form
method="POST"
action="/admin/users/approve-all"
>
<button
class="btn btn-sm btn-primary"
type="submit"
>
Accept all
</button>
</form>
<form
method="POST"
action="/admin/users/deny-all"
>
<button
class="btn btn-sm btn-danger"
type="submit"
>
Deny all
</button>
</form>
</div>
<ul class="queue-list">
{pendingUsers.map((u) => (
<li class="queue-item">
<div class="queue-item-meta">
<div class="queue-item-header">
<strong>{u.username}</strong>
<span class="queue-item-date">
{formatDateTime(
u.created_at,
)}
</span>
</div>
{u.register_application && (
<p class="queue-item-answer">
{u.register_application}
</p>
)}
</div>
<div class="queue-item-actions">
<form
method="POST"
action="/admin/users/approve"
>
<input
type="hidden"
name="id"
value={String(u.id)}
/>
<button
class="btn btn-sm btn-primary"
type="submit"
>
Accept
</button>
</form>
<form
method="POST"
action="/admin/users/deny"
>
<input
type="hidden"
name="id"
value={String(u.id)}
/>
<button
class="btn btn-sm btn-danger"
type="submit"
>
Deny
</button>
</form>
</div>
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Admin: User Management */}
{user.isAdmin && (
<div class="form-card">
Msrc/views/auth/Register.tsx
@@ -2,9 +2,28 @@ import { Layout } from "../layout.tsx";
interface RegisterProps {
error?: string;
question?: string;
pending?: boolean;
}
export function Register({ error }: RegisterProps) {
export function Register({ error, question, pending }: RegisterProps) {
if (pending) {
return (
<Layout user={null} title="Register">
<div class="auth-container">
<h1 class="page-title">Create account</h1>
<p class="form-success">
Your account has been submitted for review. You will be
able to log in once an admin approves it.
</p>
<p class="auth-footer">
Already have an account? <a href="/login">Sign in</a>
</p>
</div>
</Layout>
);
}
return (
<Layout user={null} title="Register">
<div class="auth-container">
@@ -23,6 +42,18 @@ export function Register({ error }: RegisterProps) {
title="Letters, numbers, hyphens and underscores only"
/>
</div>
{question && (
<div class="form-group">
<label for="application">{question}</label>
<textarea
id="application"
name="application"
rows="4"
required
placeholder="Your answer..."
></textarea>
</div>
)}
<div id="passkey-section" style="display:none">
<button
id="passkey-register-btn"
@@ -66,6 +97,7 @@ export function Register({ error }: RegisterProps) {
<script type="module">{`
import { startRegistration } from '/assets/simplewebauthn-browser.js';
const usernameInput = document.getElementById('username');
const applicationInput = document.getElementById('application');
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('passkey-register-btn').addEventListener('click', async () => {
const username = usernameInput.value.trim();
@@ -74,18 +106,32 @@ export function Register({ error }: RegisterProps) {
alert('Username may only contain letters, numbers, hyphens, and underscores');
return;
}
if (applicationInput && !applicationInput.value.trim()) {
applicationInput.focus();
return;
}
try {
const createResp = await fetch('/auth/passkey/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
body: JSON.stringify({
username,
application: applicationInput ? applicationInput.value.trim() : undefined,
}),
});
if (!createResp.ok) {
const err = await createResp.json();
alert(err.error ?? 'Failed to create account');
return;
}
await createResp.json();
const data = await createResp.json();
if (data.pending) {
document.querySelector('.auth-container').innerHTML =
'<h1 class="page-title">Create account</h1>' +
'<p class="form-success">Your account has been submitted for review. You will be able to log in once an admin approves it.</p>' +
'<p class="auth-footer">Already have an account? <a href="/login">Sign in</a></p>';
return;
}
const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });