Files
website/src/lib/server/daisy-hook.ts

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';
}