feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit
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>
This commit is contained in:
83
app/api/cron/route.ts
Normal file
83
app/api/cron/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user