allow patch updates
MREADME.md
@@ -34,13 +34,14 @@ bun run start # http://localhost:3000, SSH on port 2222
Default admin credentials: `admin` / `changeme`
## Manual repository creation
## Manual repository interactions
Repositories can bei either created through the UI, or existing ones can be copied manually to the `data/repos` directory.
Existing Repositories can be copied manually to the `data/repos` directory.
Non-bare repos are automatically converted to bare repos on startup, discarding uncomitted changes and worktrees.
Pushing directly to the repositories (i.e. not through the bundled http/ssh endpoints) should generally works as well.
### Docker / Podman
Container images are provided.
Container image and compose file are provided.
```bash
docker compose up # or
podman compose up
@@ -88,13 +89,11 @@ 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 # Playwright E2E 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, 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
- edit patches
- show ^M in diffs
- registration queue
Mpackage.json
@@ -7,7 +7,7 @@
"css:build": "bun scripts/build-css.ts",
"db:init": "bun run src/db/init.ts",
"db:seed": "bun run src/db/seed.ts",
"test": "bun test tests/highlight.test.ts && bun test tests/e2e.test.ts --timeout 60000",
"test": "bun test --timeout 60000",
"vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
"vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
"postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build",
Msrc/db/index.ts
@@ -86,6 +86,7 @@ interface PatchTable {
created_at: string;
updated_at: string;
edited_at: string | null;
version: string;
}
interface PatchCommentTable {
@@ -168,6 +169,19 @@ const sqlite = new BunDatabase(DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
// Migration: add version column if missing, then populate any empty values
const patchCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(patches)")
.all();
if (!patchCols.some((c) => c.name === "version")) {
sqlite.run(
"ALTER TABLE patches ADD COLUMN version TEXT NOT NULL DEFAULT ''",
);
}
sqlite.run(
"UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
);
export const db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
Msrc/db/schema.sql
@@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS patches (
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
edited_at TEXT,
version TEXT NOT NULL DEFAULT '',
UNIQUE(repo_id, number)
);
Msrc/routes/patches.tsx
@@ -266,6 +266,7 @@ export const patchRoutes = new Elysia()
author_email: uploadMeta.email,
created_at: now,
updated_at: now,
version: crypto.randomUUID(),
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -313,6 +314,7 @@ export const patchRoutes = new Elysia()
"patches.created_at",
"patches.updated_at",
"patches.edited_at",
"patches.version",
"users.username as author_username",
"users.avatar_version as author_avatar_version",
])
@@ -416,60 +418,159 @@ export const patchRoutes = new Elysia()
},
)
.post("/:repo/patches/:number/merge", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
.post(
"/:repo/patches/:number/merge",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "patch_content", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "patch_content", "status", "version"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
// Atomically claim the merge slot before the slow git operation to
// prevent two concurrent requests from both applying the same patch.
const claimed = await db
.updateTable("patches")
.set({ status: "merged", updated_at: new Date().toISOString() })
.where("id", "=", patch.id)
.where("status", "=", "open")
.executeTakeFirst();
if (!claimed || claimed.numUpdatedRows === 0n)
return new Response("Patch is not open", { status: 400 });
// Reject if the patch file was changed after the admin loaded the page
if (body.version !== patch.version) {
return new Response(
"The patch file was updated after you loaded this page. Please review the new version before merging.",
{ status: 409 },
);
}
const mergeMeta = extractPatchMeta(patch.patch_content);
try {
await git.applyPatch(
repo.name,
patch.patch_content,
mergeMeta.author,
mergeMeta.email,
COMMITTER_NAME,
COMMITTER_EMAIL,
);
} catch (err) {
// Roll back the status if the git operation fails
// Atomically claim the merge slot before the slow git operation to
// prevent two concurrent requests from both applying the same patch.
const claimed = await db
.updateTable("patches")
.set({ status: "merged", updated_at: new Date().toISOString() })
.where("id", "=", patch.id)
.where("status", "=", "open")
.where("version", "=", patch.version)
.executeTakeFirst();
if (!claimed || claimed.numUpdatedRows === 0n)
return new Response("Patch is not open", { status: 400 });
const mergeMeta = extractPatchMeta(patch.patch_content);
try {
await git.applyPatch(
repo.name,
patch.patch_content,
mergeMeta.author,
mergeMeta.email,
COMMITTER_NAME,
COMMITTER_EMAIL,
);
} catch (err) {
// Roll back the status if the git operation fails
await db
.updateTable("patches")
.set({
status: "open",
updated_at: new Date().toISOString(),
})
.where("id", "=", patch.id)
.execute();
throw err;
}
patchCache.invalidate(patch.id);
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
body: t.Object({ version: t.String() }),
},
)
.post(
"/:repo/patches/:number/upload",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "author_id", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
if (patch.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
if (patch.status !== "open")
return new Response("Patch is not open", { status: 400 });
if (!body.patch_file || body.patch_file.size === 0) {
return new Response("Patch file is required", { status: 400 });
}
if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
return new Response("Patch file is too large", { status: 400 });
}
const patchContent = await body.patch_file.text();
if (!patchContent.trim()) {
return new Response("Patch file is empty", { status: 400 });
}
if (!isValidPatch(patchContent)) {
return new Response(
"File does not appear to be a valid patch file",
{ status: 400 },
);
}
const uploadMeta = extractPatchMeta(patchContent);
if (
!uploadMeta.subject ||
!uploadMeta.author ||
!uploadMeta.email ||
!uploadMeta.date
) {
return new Response(
"Patch is missing required headers (Subject, From, Date)",
{ status: 400 },
);
}
const newVersion = crypto.randomUUID();
await db
.updateTable("patches")
.set({ status: "open", updated_at: new Date().toISOString() })
.set({
patch_content: patchContent,
author_name: uploadMeta.author,
author_email: uploadMeta.email,
version: newVersion,
updated_at: new Date().toISOString(),
})
.where("id", "=", patch.id)
.execute();
throw err;
}
patchCache.invalidate(patch.id);
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
})
patchCache.invalidate(patch.id);
runPatchCheck(repo.name, patch.id, patchContent);
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
body: t.Object({
patch_file: t.Optional(t.File()),
}),
},
)
.post("/:repo/patches/:number/close", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
Msrc/views/patches/PatchDetail.tsx
@@ -133,6 +133,11 @@ export function PatchDetail({
action={`${baseUrl}/merge`}
class="inline-form"
>
<input
type="hidden"
name="version"
value={patch.version}
/>
<button
type="submit"
class="btn btn-sm btn-primary"
@@ -141,6 +146,35 @@ export function PatchDetail({
</button>
</form>
)}
{patch.status === "open" && (
<details class="confirm-details">
<summary class="btn btn-sm">
Update patch file
</summary>
<div class="confirm-popup">
<form
method="POST"
action={`${baseUrl}/upload`}
enctype="multipart/form-data"
class="upload-patch-form"
>
<input
type="file"
name="patch_file"
accept=".patch"
required
class="form-input"
/>
<button
type="submit"
class="btn btn-sm btn-primary"
>
Upload
</button>
</form>
</div>
</details>
)}
{user?.isAdmin && patch.status !== "merged" && (
<form
method="POST"
Mtests/e2e.test.ts
@@ -1037,6 +1037,194 @@ describe('patches', () => {
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
// ── Patch file re-upload & version protection ──────────────────────────────
let uploadTestPatchUrl: string;
// Adds upload-test.txt — applies cleanly to my-repo
const UPLOAD_TEST_PATCH = [
'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
'From: Original Author <original@example.com>',
'Date: Wed, 03 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test',
'',
].join('\n');
// Replacement: different author, same diff target
const REPLACEMENT_PATCH = [
'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
'From: Replaced Author <replaced@example.com>',
'Date: Thu, 04 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt (v2)',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test v2',
'',
].join('\n');
test('create patch for re-upload tests', async () => {
writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Upload test patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
uploadTestPatchUrl = page.url();
} finally { await page.close(); }
});
test('upload patch file button is visible for admin on open patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
} finally { await page.close(); }
});
test('non-author non-admin cannot upload patch file', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await aliceCtx.close(); }
});
test('upload button hidden for non-author non-admin', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await aliceCtx.close(); }
});
test('admin can upload replacement patch file', async () => {
writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
// Author info should reflect the replacement patch
expect(await page.locator('.patch-author-identity').textContent()).toContain('Replaced Author');
expect(await page.locator('.patch-author-identity').textContent()).toContain('replaced@example.com');
} finally { await page.close(); }
});
test('merge fails when version token is stale', async () => {
// Patch that adds stale-version.txt — applies cleanly
const STALE_PATCH = [
'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Fri, 05 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add stale-version.txt',
'',
'---',
'diff --git a/stale-version.txt b/stale-version.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/stale-version.txt',
'@@ -0,0 +1 @@',
'+stale',
'',
].join('\n');
const STALE_PATCH_V2 = STALE_PATCH
.replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
.replace('+stale', '+stale v2');
writeTempFile('/tmp/stale.patch', STALE_PATCH);
const page = await adminCtx.newPage();
try {
// Create the patch
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Stale version test');
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
const stalePatchUrl = page.url();
const patchNum = stalePatchUrl.split('/patches/')[1];
// Capture the version the admin sees on the page
const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
// Author uploads a new patch file (simulated by admin here), bumping the version
writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
// Admin tries to merge with the stale version — should be rejected
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
form: { version: staleVersion },
maxRedirects: 0,
});
expect(resp.status()).toBe(409);
expect(await resp.text()).toContain('updated');
// Patch status must still be open
const checkResp = await page.request.get(stalePatchUrl);
expect(checkResp.status()).toBe(200);
expect(await checkResp.text()).toContain('open');
} finally { await page.close(); }
});
test('merge succeeds with current version token after replacement upload', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
// Merged commit should carry the replacement patch's author
const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
expect(authorName).toBe('Replaced Author');
} finally { await page.close(); }
});
test('upload patch file button hidden on merged patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await page.close(); }
});
test('POST to upload on merged patch returns 400', async () => {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(400);
});
});
// ─── Pagination ───────────────────────────────────────────────────────────────