All checks were successful
Deploy production / deploy (push) Successful in 1m7s
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
// "Let Daisy handle it" on quick add: tell Daisy's routine about the new task.
|
|
// Configured in /etc/personal-website/prod.env:
|
|
// DAISY_TASK_WEBHOOK_URL where to POST {task_id, title}
|
|
// DAISY_TASK_WEBHOOK_KEY secret sent with it
|
|
// DAISY_TASK_WEBHOOK_HEADER header name (default Authorization, sent as "Bearer <key>";
|
|
// any other header gets the bare key). A full "Name: value"
|
|
// line is also accepted (only the name is used).
|
|
// Unset URL or key: nothing is sent and the task is still saved.
|
|
|
|
export type DaisyHookConfig = { url: string; headers: Record<string, string>; secrets: string[] };
|
|
|
|
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
|
|
export function daisyHookConfig(env: Record<string, string | undefined> = process.env) {
|
|
const url = env.DAISY_TASK_WEBHOOK_URL?.trim();
|
|
const key = env.DAISY_TASK_WEBHOOK_KEY?.trim();
|
|
if (!url || !key) return null;
|
|
// The header once got set to a whole "Authorization: Bearer <key>" line: keep only the name.
|
|
let header = (env.DAISY_TASK_WEBHOOK_HEADER ?? '').split(':')[0].trim() || 'Authorization';
|
|
if (!HEADER_NAME.test(header)) {
|
|
console.error(
|
|
'[daisy-hook] DAISY_TASK_WEBHOOK_HEADER is not a valid header name, using Authorization'
|
|
);
|
|
header = 'Authorization';
|
|
}
|
|
const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key;
|
|
return {
|
|
url,
|
|
headers: { 'content-type': 'application/json', [header]: value },
|
|
secrets: [key, url]
|
|
} satisfies DaisyHookConfig;
|
|
}
|
|
|
|
/** Strip the key and URL (it may carry a token) from anything that gets logged. */
|
|
export function redact(message: string, secrets: string[] = []) {
|
|
let out = message;
|
|
for (const s of secrets) if (s) out = out.split(s).join('[redacted]');
|
|
return out
|
|
.replace(/crsr_[A-Za-z0-9]+/g, '[redacted]')
|
|
.replace(/Bearer\s+[^\s'"]+/gi, 'Bearer [redacted]');
|
|
}
|
|
|
|
async function post(config: DaisyHookConfig, body: string, fetcher: typeof fetch) {
|
|
try {
|
|
const res = await fetcher(config.url, {
|
|
method: 'POST',
|
|
headers: config.headers,
|
|
body,
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
return res.ok ? null : `HTTP ${res.status}`;
|
|
} catch (err: unknown) {
|
|
// Name/code only: some fetch errors echo request headers (and so the key).
|
|
const e = err as { name?: string; cause?: { code?: string } };
|
|
return `${e?.name ?? 'Error'} ${e?.cause?.code ?? ''}`.trim();
|
|
}
|
|
}
|
|
|
|
/** Fire and forget: never awaited by the request; one retry after 2 s; failures logged redacted. */
|
|
export function notifyDaisy(
|
|
task: { id: number; title: string },
|
|
config: DaisyHookConfig | null = daisyHookConfig(),
|
|
fetcher: typeof fetch = fetch,
|
|
retryDelayMs = 2000
|
|
): 'sent' | 'not-configured' {
|
|
if (!config) return 'not-configured';
|
|
const body = JSON.stringify({ task_id: task.id, title: task.title });
|
|
void (async () => {
|
|
let failure = await post(config, body, fetcher);
|
|
if (failure) {
|
|
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
failure = await post(config, body, fetcher);
|
|
}
|
|
if (failure) console.error(`[daisy-hook] task ${task.id}: ${redact(failure, config.secrets)}`);
|
|
else console.log(`[daisy-hook] task ${task.id}: delivered`);
|
|
})();
|
|
return 'sent';
|
|
}
|