105 lines
3.2 KiB
TypeScript
105 lines
3.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { detectSetupState } from '@/lib/setup/state';
|
|
import { authenticateWizardRequest } from '@/lib/setup/session';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
|
|
const FETCH_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
|
|
* the check_jmap_server() helper in setup.sh: we hit a few common session
|
|
* endpoints and look for capability strings to confirm the URL is actually
|
|
* a JMAP server (vs. a generic HTTP 200 page).
|
|
*
|
|
* Body: { url: string }
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
if (detectSetupState() !== 'bootstrap') {
|
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
|
}
|
|
if (!(await authenticateWizardRequest())) {
|
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
|
}
|
|
|
|
let body: { url?: unknown };
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
|
}
|
|
|
|
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
if (!raw) {
|
|
return NextResponse.json({ error: 'url required' }, { status: 400 });
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(raw);
|
|
} catch {
|
|
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
|
|
}
|
|
|
|
const base = raw.replace(/\/+$/, '');
|
|
|
|
for (const endpoint of JMAP_ENDPOINTS) {
|
|
const target = base + endpoint;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
const res = await fetch(target, {
|
|
method: 'GET',
|
|
redirect: 'follow',
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
|
|
if (!res.ok) continue;
|
|
const text = await res.text();
|
|
if (looksLikeJmapSession(text)) {
|
|
return NextResponse.json({
|
|
status: 'jmap_detected',
|
|
endpoint,
|
|
httpStatus: res.status,
|
|
});
|
|
}
|
|
} catch {
|
|
// Try the next endpoint; we'll fall through to a final reachability
|
|
// check below if none match.
|
|
}
|
|
}
|
|
|
|
// No JMAP session found. Was the server even reachable?
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
const res = await fetch(base, {
|
|
method: 'HEAD',
|
|
redirect: 'follow',
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
return NextResponse.json({
|
|
status: 'reachable_no_jmap',
|
|
httpStatus: res.status,
|
|
message:
|
|
'Server responded but no JMAP session was found at standard paths. ' +
|
|
'This is OK if a reverse proxy routes JMAP separately.',
|
|
});
|
|
} catch (error) {
|
|
return NextResponse.json({
|
|
status: 'unreachable',
|
|
message: error instanceof Error ? error.message : 'Connection failed',
|
|
});
|
|
}
|
|
}
|
|
|
|
function looksLikeJmapSession(body: string): boolean {
|
|
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
|
|
}
|