diff --git a/app/api/cron/route.ts b/app/api/cron/route.ts new file mode 100644 index 0000000..9111f6e --- /dev/null +++ b/app/api/cron/route.ts @@ -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>; + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..2eab7ce --- /dev/null +++ b/app/api/tasks/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 0000000..d88ede7 --- /dev/null +++ b/app/api/tasks/route.ts @@ -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 }); + } +} diff --git a/app/dapp/page.tsx b/app/dapp/page.tsx index 2ba0978..4a36904 100644 --- a/app/dapp/page.tsx +++ b/app/dapp/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract, useSendTransaction } from 'wagmi'; +import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract } from 'wagmi'; import { base, arbitrum, mainnet } from 'wagmi/chains'; import { Abi, parseUnits } from 'viem'; import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json'; @@ -76,7 +76,7 @@ export default function DappPage() { const [payInputs, setPayInputs] = useState>({}); const publicClient = usePublicClient({ chainId: selectedChainId }); const { writeContractAsync, isPending: writePending } = useWriteContract(); - const { sendTransactionAsync, isPending: txPending } = useSendTransaction(); + const [compoundPending, setCompoundPending] = useState(false); // NFTCache settings (from localStorage; defaults to env or '/nftcache') const [nftcacheEnabled, setNftcacheEnabled] = useState(false); @@ -101,22 +101,13 @@ export default function DappPage() { useEffect(() => { if (searchParams?.get('settings') === '1') setSettingsOpen(true); }, [searchParams]); - const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, ''); - const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, ''); - const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim(); + const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, ''); const [notifSettings, setNotifSettings] = useLocalStorage('notif:settings', { provider: '', - scheduler: 'schedy', ntfyServer: ENV_NTFY, ntfyTopic: '', gotifyServer: '', gotifyToken: '', - snsRegion: '', - snsTopicArn: '', - snsAccessKeyId: '', - snsSecretAccessKey: '', - schedyBaseUrl: ENV_SCHEDY, - schedyApiKey: ENV_SCHEDY_API_KEY, email: '', backupEmail: '', backupDelayDays: 1, @@ -140,27 +131,25 @@ export default function DappPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // One-time migration: legacy scheduler providers -> schedy; remove legacy fields + // One-time migration: remove legacy and removed fields useEffect(() => { const s: any = notifSettings as any; if (!s) return; let changed = false; const next: any = { ...notifSettings }; - // Normalize scheduler - if (!s.scheduler || s.scheduler !== 'schedy') { - next.scheduler = 'schedy'; - changed = true; - } - // Remove legacy keys if present - const legacyKeys = ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']; - for (const k of legacyKeys) { + const removedKeys = [ + 'scheduler', 'schedyBaseUrl', 'schedyApiKey', + 'snsRegion', 'snsTopicArn', 'snsAccessKeyId', 'snsSecretAccessKey', + 'cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId' + ]; + for (const k of removedKeys) { if (k in next) { delete next[k]; changed = true; } } if (changed) { - console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields'); + console.log('[Settings] Migrating and pruning legacy/removed fields'); setNotifSettings(next as NotificationSettings); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -773,24 +762,16 @@ export default function DappPage() { headers: { 'Content-Type': 'application/json' }, }; } - if (provider === 'sns') { - // Note: Direct SNS publish requires AWS SigV4; recommend server-side relay. Here we simply target a placeholder you control. - // User should set up a relay endpoint that publishes to SNS. - const relay = '/api/sns-relay'; - return { - url: relay, - method: 'POST' as const, - body: { topicArn: notifSettings.snsTopicArn, region: notifSettings.snsRegion, message, email: notifSettings.email }, - headers: { 'Content-Type': 'application/json' }, - }; - } throw new Error('Unsupported provider'); }; // Single schedule at configured lead time (or 1 day by default) const scheduleNotification = async (row: { tokenId: bigint; secondsTillLiq?: bigint }) => { const seconds = Number(row.secondsTillLiq ?? 0); - if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Invalid liquidation timer'); + const MAX_SECONDS = 86400 * 365 * 5; // 5 years max + if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_SECONDS) { + throw new Error('Invalid liquidation timer'); + } const nowSec = Math.floor(Date.now() / 1000); const leadSecsCfg = Math.max(0, Math.floor(Number(notifSettings.daysBefore ?? 0) * 86400)); const offset = leadSecsCfg > 0 ? leadSecsCfg : 86400; // default to 1 day @@ -842,7 +823,7 @@ export default function DappPage() { if (runAt1 <= nowSec) throw new Error('Computed run time is in the past'); // Primary job (to main email) const req1 = buildNotificationRequest(row, msg); - const res1 = await scheduleJob(notifSettings, { runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers }); + const res1 = await scheduleJob({ runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers }); // Optional backup job const jobs: Array<{ id: string; at: number; label: 'lead' | 'half' | 'last' }> = [ @@ -861,7 +842,7 @@ export default function DappPage() { const msgBackupCore = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeftBackup}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; const backupMsg = `${prefix}\n\n${msgBackupCore}`; const req2 = buildNotificationRequest(row, backupMsg, { email: bEmail }); - const res2 = await scheduleJob(notifSettings, { runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers }); + const res2 = await scheduleJob({ runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers }); jobs.push({ id: res2.jobId, at: runAt2, label: 'last' }); } } @@ -873,12 +854,12 @@ export default function DappPage() { // Cancel new multi-jobs first if (p.jobs && p.jobs.length) { for (const j of p.jobs) { - try { await cancelScheduledJob(notifSettings, j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); } + try { await cancelScheduledJob(j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); } } } // Back-compat: single jobId if (p.jobId) { - try { await cancelScheduledJob(notifSettings, p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); } + try { await cancelScheduledJob(p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); } } }; @@ -1032,37 +1013,55 @@ export default function DappPage() { }; // Compound action: raw tx to debt token contract with provided data + // Only allowed against known preset addresses to prevent arbitrary contract calls const handleCompound = async () => { try { if (!debtAddress) return; - await sendTransactionAsync({ - to: debtAddress as `0x${string}`, - data: '0x4e71d92d', + const isKnownPreset = Object.values(PRESETS).some( + (list) => list?.some((p) => p.debt.toLowerCase() === debtAddress.toLowerCase()) + ); + if (!isKnownPreset) { + console.warn('[Compound] Rejected: debt address is not a known preset'); + return; + } + setCompoundPending(true); + await writeContractAsync({ + abi: debtAbi as Abi, + address: debtAddress as `0x${string}`, + functionName: 'compound', chainId: selectedChainId, }); } catch (e) { console.warn('[Compound] Failed', e); + } finally { + setCompoundPending(false); } }; return ( -
-

MortgageFi DApp

-

Connect wallet, detect your NFTs, and fetch debt details.

+
+
+

🌾 your quiet little vault

+

MortgageFi DApp

+

Connect your wallet, find your NFTs, and tend to your debt β€” gently.

+
{!isConnected && ( -
Please connect your wallet using the Connect Wallet button in the navbar.
+
+ πŸͺ§ + Please connect your wallet using the Connect Wallet button in the navbar. +
)}
-
-
Inputs
-