feat(actions): backfill and webhook secret rotation

This commit is contained in:
Dave 2026-09-16 11:49:02 +02:00
parent c5246f66f3
commit 0236a0b1eb

80
src/app/actions/ingest.ts Normal file
View file

@ -0,0 +1,80 @@
"use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache";
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import { ingestProject } from "@/lib/git/ingest";
import { repoExists } from "@/lib/git/forgejo";
import type { ProjectRecord } from "@/lib/pb/types";
/** See the security note at the top of src/app/actions/cards.ts. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
/**
* Generate or replace a project's webhook secret.
*
* Returned once so the owner can paste it into Forgejo. It is stored and can be
* read again by the owner's admin page — unlike a user password, this is a
* shared secret that both ends need, so there is no value in making it
* unrecoverable. It must still never reach a client manager's browser.
*/
export async function rotateWebhookSecret(input: {
projectId: string;
}): Promise<{ secret: string }> {
await requireOwner();
const secret = `whsec_${randomBytes(24).toString("hex")}`;
const pb = await getAdminPb();
await pb
.collection("projects")
.update(input.projectId, { webhook_secret: secret });
revalidatePath(`/admin/projects/${input.projectId}`);
return { secret };
}
/**
* Pull the full history for a project.
*
* `stopAfterKnownPages: Infinity` reads to the end rather than stopping at the
* first familiar page — that is the whole difference between a backfill and the
* hourly poll, and it is why both share one function.
*
* WFDASH-WEBAPP is 787 commits, so this is 16 pages at 50 per page. It is slow
* enough to want a progress indication in the UI and fast enough not to need a
* job queue.
*/
export async function backfillProject(input: {
projectId: string;
}): Promise<{ created: number; skipped: number }> {
await requireOwner();
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(input.projectId);
if (!project.repo_owner || !project.repo_name) {
throw new Error("Link a repository to this project first");
}
if (!(await repoExists(project.repo_owner, project.repo_name))) {
throw new Error(
`Forgejo has no repository ${project.repo_owner}/${project.repo_name}, or it is not readable`
);
}
const result = await ingestProject(project, {
stopAfterKnownPages: Number.POSITIVE_INFINITY,
});
revalidatePath(`/admin/projects/${input.projectId}`);
return { created: result.created, skipped: result.skipped };
}