UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin of navbar, connect button, dapp page, loan cards, settings modal, and readme. Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched. Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]], callouts, Home MOC, folders Architecture/Operations/Audits) and added a full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy key value from the security audit example (rotate it at Schedy). Also commits the previously-untracked server layer: app/api (cron + tasks routes) and lib (redis, ssrf-guard, task-store). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
2.4 KiB
TypeScript
84 lines
2.4 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { listDueTasks, deleteTask } from '@/lib/task-store';
|
|
import { sanitizeHeaders } from '@/lib/ssrf-guard';
|
|
|
|
const CRON_SECRET = process.env.CRON_SECRET;
|
|
|
|
export async function GET(request: NextRequest) {
|
|
// Protect cron endpoint: require Authorization header with CRON_SECRET
|
|
const auth = request.headers.get('authorization') || '';
|
|
const expected = CRON_SECRET ? `Bearer ${CRON_SECRET}` : '';
|
|
if (!CRON_SECRET || auth !== expected) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
let tasks: Awaited<ReturnType<typeof listDueTasks>>;
|
|
try {
|
|
tasks = await listDueTasks(nowSec);
|
|
} catch (e: any) {
|
|
return NextResponse.json({ error: e?.message || 'Failed to fetch tasks' }, { status: 500 });
|
|
}
|
|
|
|
const results: Array<{ id: string; ok: boolean; error?: string }> = [];
|
|
|
|
for (const task of tasks) {
|
|
let ok = false;
|
|
let lastError: string | undefined;
|
|
|
|
for (let attempt = 0; attempt <= task.retries; attempt++) {
|
|
if (attempt > 0) {
|
|
await sleep(task.retryInterval);
|
|
}
|
|
|
|
try {
|
|
const bodyBytes = encodePayload(task.payload);
|
|
const res = await fetch(task.url, {
|
|
method: 'POST',
|
|
headers: {
|
|
...sanitizeHeaders(task.headers),
|
|
'content-type': guessContentType(task.payload),
|
|
},
|
|
body: bodyBytes,
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
|
|
if (res.ok) {
|
|
ok = true;
|
|
break;
|
|
}
|
|
lastError = `HTTP ${res.status}`;
|
|
} catch (e: any) {
|
|
lastError = e?.message || 'Network error';
|
|
}
|
|
}
|
|
|
|
if (ok) {
|
|
try {
|
|
await deleteTask(task.id);
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
|
|
results.push({ id: task.id, ok, error: ok ? undefined : lastError });
|
|
}
|
|
|
|
return NextResponse.json({ executed: results.length, results });
|
|
}
|
|
|
|
function encodePayload(payload: any): string {
|
|
if (payload === null || payload === undefined) return '';
|
|
if (typeof payload === 'string') return payload;
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
function guessContentType(payload: any): string {
|
|
if (typeof payload === 'string') return 'text/plain';
|
|
return 'application/json';
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|