48 lines
1.7 KiB
TypeScript
48 lines
1.7 KiB
TypeScript
import { createHash, timingSafeEqual } from "node:crypto";
|
|
import { writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* POST /api/deploy
|
|
*
|
|
* Drops the redeploy flag that scripts/deploy-watch.sh polls for. Requires
|
|
* `Authorization: Bearer <DEPLOY_TRIGGER_SECRET>`.
|
|
*
|
|
* DEPLOY_TRIGGER_DIR must point at the same directory the watcher reads
|
|
* (mounted into the container by docker-compose.yml).
|
|
*/
|
|
|
|
const TRIGGER_DIR = process.env.DEPLOY_TRIGGER_DIR ?? "/deploy-trigger";
|
|
const TRIGGER_FILE = path.join(TRIGGER_DIR, "redeploy.flag");
|
|
|
|
// Hashing both sides gives equal-length buffers, so the comparison is
|
|
// constant-time regardless of the supplied token's length.
|
|
function tokenMatches(supplied: string, expected: string): boolean {
|
|
const a = createHash("sha256").update(supplied).digest();
|
|
const b = createHash("sha256").update(expected).digest();
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const secret = process.env.DEPLOY_TRIGGER_SECRET;
|
|
if (!secret) {
|
|
// Not configured: behave as if the route doesn't exist.
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
|
|
const header = request.headers.get("authorization") ?? "";
|
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
if (!match || !tokenMatches(match[1].trim(), secret)) {
|
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
try {
|
|
await writeFile(TRIGGER_FILE, new Date().toISOString() + "\n");
|
|
} catch (err) {
|
|
console.error("[deploy] failed to write trigger file", TRIGGER_FILE, err);
|
|
return Response.json({ error: "failed to queue redeploy" }, { status: 500 });
|
|
}
|
|
|
|
return Response.json({ status: "queued" }, { status: 202 });
|
|
}
|