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:
Siavash Sameni
2026-06-14 08:13:53 +04:00
parent cf76322008
commit 6ae581ab2e
25 changed files with 4245 additions and 369 deletions

83
app/api/cron/route.ts Normal file
View 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));
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { deleteTask } from '@/lib/task-store';
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!id || !id.startsWith('task_')) {
return NextResponse.json({ error: 'Invalid task id' }, { status: 400 });
}
try {
await deleteTask(id);
return new NextResponse(null, { status: 204 });
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to delete task' }, { status: 500 });
}
}

72
app/api/tasks/route.ts Normal file
View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { saveTask, listAllTasks, checkRateLimit } from '@/lib/task-store';
import { isAllowedUrl, sanitizeHeaders } from '@/lib/ssrf-guard';
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? 'unknown';
const allowed = await checkRateLimit(ip, 30, 3600); // 30 tasks/hour per IP
if (!allowed) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const url = typeof body.url === 'string' ? body.url : '';
const executeAt = typeof body.execute_at === 'number' ? body.execute_at : 0;
const headers = typeof body.headers === 'object' && body.headers !== null ? body.headers : {};
const payload = body.payload ?? null;
const retries = typeof body.retries === 'number' ? Math.max(0, Math.min(body.retries, 10)) : 3;
const retryInterval = typeof body.retry_interval === 'number' ? Math.max(1000, body.retry_interval) : 5000;
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const urlCheck = isAllowedUrl(url);
if (!urlCheck.ok) {
return NextResponse.json({ error: `Blocked URL: ${urlCheck.reason}` }, { status: 400 });
}
const nowSec = Math.floor(Date.now() / 1000);
if (executeAt <= nowSec) {
return NextResponse.json({ error: 'execute_at must be in the future' }, { status: 400 });
}
if (executeAt > nowSec + 86400 * 365 * 2) {
return NextResponse.json({ error: 'execute_at too far in the future (max 2 years)' }, { status: 400 });
}
const id = `task_${crypto.randomUUID()}`;
const task = {
id,
url,
executeAt,
headers: sanitizeHeaders(headers),
payload,
retries,
retryInterval,
createdAt: nowSec,
};
try {
await saveTask(task);
return NextResponse.json(task, { status: 201 });
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to save task' }, { status: 500 });
}
}
export async function GET() {
try {
const tasks = await listAllTasks();
return NextResponse.json(tasks);
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to list tasks' }, { status: 500 });
}
}