refactor: route anonymize preview through the backend

The form's live README/PR preview was running its own copy of
ContentAnonimizer in the browser. The two implementations had been
drifting — recent fixes for word boundaries (#175/#249), accent
matching (#280), custom replacements (#285), and the diacritic-stripped
variants only landed on the server. Reviewers saw one anonymization;
authors composing the form saw another.

Add POST /api/anonymize-preview that takes a snippet (or a batch) plus
the user's options and runs them through the same ContentAnonimizer
the file route uses. Replace the client-side anonymizeReadme() body
with a debounced call to that endpoint. The PR view's
anonymizePrContent() runs as a synchronous template expression, so it
now reads from a {original -> anonymized} cache that's refreshed in
the background whenever the PR details, terms, or options change.

Single-flight + debounce keep the form responsive; an in-flight
request is dropped on the next change.
This commit is contained in:
tdurieux
2026-05-04 11:05:50 +02:00
parent c8fc561dac
commit 117406f2ce
5 changed files with 346 additions and 190 deletions
+1
View File
@@ -161,6 +161,7 @@ export default async function start() {
apiRouter.use("/repo", speedLimiter, router.repositoryPrivate);
apiRouter.use("/pr", speedLimiter, router.pullRequestPublic);
apiRouter.use("/pr", speedLimiter, router.pullRequestPrivate);
apiRouter.use("/anonymize-preview", speedLimiter, router.anonymizePreview);
apiRouter.get("/message", async (_, res) => {
if (existsSync("./message.txt")) {
+70
View File
@@ -0,0 +1,70 @@
import * as express from "express";
import { ContentAnonimizer } from "../../core/anonymize-utils";
import { handleError } from "./route-utils";
import { ensureAuthenticated } from "./connection";
const router = express.Router();
router.use(ensureAuthenticated);
// Anonymize one or more snippets of content with the same logic the backend
// uses on real anonymized files. The form's preview was running a duplicate
// of ContentAnonimizer in the browser, which drifted from the backend (missed
// fixes for word boundaries, accent matching, custom replacements, etc.).
// Routing the preview through this endpoint keeps the two in lockstep.
//
// Accepts either { content: string } (single) or { contents: string[] }
// (batch) so the PR preview can scrub many fields in one round trip.
router.post("/", async (req: express.Request, res: express.Response) => {
try {
const body: {
content?: unknown;
contents?: unknown;
options?: {
terms?: string[];
image?: boolean;
link?: boolean;
repoName?: string;
branchName?: string;
repoId?: string;
};
} = req.body || {};
let inputs: string[];
let single = false;
if (typeof body.content === "string") {
inputs = [body.content];
single = true;
} else if (
Array.isArray(body.contents) &&
body.contents.every((c) => typeof c === "string")
) {
inputs = body.contents as string[];
} else {
return res.status(400).json({ error: "missing_content" });
}
const opt = body.options || {};
// Construct one anonymizer per snippet so the wasAnonymized flag is per
// input. ContentAnonimizer is cheap to instantiate.
const outputs = inputs.map((content) => {
const a = new ContentAnonimizer({
terms: Array.isArray(opt.terms) ? opt.terms : [],
image: opt.image,
link: opt.link,
repoName: opt.repoName,
branchName: opt.branchName,
repoId: opt.repoId,
});
return a.anonymize(content);
});
if (single) {
return res.json({ content: outputs[0] });
}
res.json({ contents: outputs });
} catch (error) {
handleError(error, res, req);
}
});
export default router;
+2
View File
@@ -8,6 +8,7 @@ import webview from "./webview";
import user from "./user";
import option from "./option";
import admin from "./admin";
import anonymizePreview from "./anonymize-preview";
export default {
pullRequestPrivate,
@@ -20,4 +21,5 @@ export default {
option,
conference,
admin,
anonymizePreview,
};