Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||
|
||||
Binary file not shown.
@@ -1,8 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
|
||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.svg': 'image/svg+xml',
|
||||
@@ -38,11 +41,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = path.join(BRANDING_DIR, safe);
|
||||
const filePath = path.join(getBrandingDir(), safe);
|
||||
|
||||
// Ensure resolved path is still within BRANDING_DIR
|
||||
// Ensure resolved path is still within getBrandingDir()
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
}
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
@@ -41,7 +44,7 @@ function sanitizeFilename(name: string): string {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(BRANDING_DIR, safeName);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(BRANDING_DIR)) {
|
||||
await mkdir(BRANDING_DIR, { recursive: true });
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
@@ -111,7 +114,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -2,22 +2,45 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Strings that count as "no real secret configured" — used so the dashboard
|
||||
// can warn about a placeholder session secret without us ever returning the
|
||||
// raw value to the client.
|
||||
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
||||
|
||||
/**
|
||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||
*
|
||||
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
|
||||
* `value` omitted and a `hasValue` boolean instead. An admin session is
|
||||
* enough to read every other config knob; the secrets themselves stay on
|
||||
* the server so that an XSS or session-theft can't lift them in one
|
||||
* request and forge admin/user session cookies offline.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const config = configManager.getAllWithSources();
|
||||
|
||||
return NextResponse.json(config, {
|
||||
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
|
||||
for (const [key, entry] of Object.entries(config)) {
|
||||
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||
const v = entry.value;
|
||||
const hasValue =
|
||||
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
|
||||
safe[key] = { source: entry.source, hasValue };
|
||||
} else {
|
||||
safe[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(safe, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -31,7 +54,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -86,7 +109,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -19,11 +19,11 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
@@ -27,7 +28,7 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
@@ -92,7 +93,7 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -163,6 +164,18 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Resolve and strictly validate the id used as a filename. Marketplace
|
||||
// bundles are authored by a third-party publisher; without this an id
|
||||
// like "../../foo" causes savePlugin/saveTheme to write outside the
|
||||
// plugins/themes dir via path.join.
|
||||
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
|
||||
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'theme') {
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
@@ -182,7 +195,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const theme: ServerTheme = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
@@ -266,8 +279,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
|
||||
? (manifest.apiPostPaths as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedApiPostPaths.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
@@ -278,17 +303,26 @@ export async function POST(request: NextRequest) {
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
|
||||
*
|
||||
* GET /api/admin/plugin-approvals → list all entries
|
||||
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
|
||||
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
|
||||
*/
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const entries = await listApprovals();
|
||||
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
if (b.decision !== 'approved' && b.decision !== 'denied') {
|
||||
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
|
||||
}
|
||||
|
||||
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
|
||||
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
|
||||
return NextResponse.json({ entry });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
await revokeApproval(pluginId, bundleHash);
|
||||
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||
|
||||
async function safeSign(code: string): Promise<string | null> {
|
||||
try { return await signBytes(code); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
@@ -25,14 +30,15 @@ export async function GET(
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readDevBundle(devEntry);
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
},
|
||||
});
|
||||
const signature = await safeSign(code);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
};
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
return new NextResponse(code, { headers });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
@@ -59,6 +65,9 @@ export async function GET(
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
const signature = await safeSign(code);
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminAuth = await requireAdminAuth();
|
||||
const adminAuth = await requireAdminAuth(request);
|
||||
const isAdmin = !('error' in adminAuth);
|
||||
|
||||
if (!isAdmin) {
|
||||
@@ -51,13 +51,18 @@ export async function GET(
|
||||
|
||||
const config = await getPluginConfig(id);
|
||||
|
||||
let response: Record<string, unknown> = config;
|
||||
if (!isAdmin && plugin.configSchema) {
|
||||
let response: Record<string, unknown>;
|
||||
if (isAdmin) {
|
||||
response = config;
|
||||
} else {
|
||||
response = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = plugin.configSchema[key];
|
||||
if (field?.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
const schema = plugin.configSchema;
|
||||
if (schema) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = schema[key];
|
||||
if (!field || field.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +85,7 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
@@ -110,6 +115,13 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (plugin.configSchema && !plugin.configSchema[body.key]) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Key is not declared in the plugin configSchema' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await setPluginConfig(id, body.key, body.value);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
@@ -127,7 +139,7 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const [registry, devEntries] = await Promise.all([
|
||||
@@ -63,7 +64,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
@@ -217,7 +222,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -259,7 +264,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
* Returns current consent + endpoint + next/last send + a live preview
|
||||
* of exactly what the next heartbeat would contain.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
@@ -61,7 +61,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getThemeRegistry();
|
||||
@@ -36,7 +36,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
* GET /api/admin/version
|
||||
* Returns the cached update status, last check times, and effective config.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const state = await loadState();
|
||||
@@ -47,7 +47,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(req);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import {
|
||||
ImpersonationJwtError,
|
||||
impersonationReplayCache,
|
||||
verifyImpersonationJwt,
|
||||
} from '@/lib/impersonation/jwt';
|
||||
import {
|
||||
readImpersonationConfig,
|
||||
resolveImpersonationServerUrl,
|
||||
} from '@/lib/impersonation/master-config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const IMPERSONATION_SLOT = 0;
|
||||
|
||||
/**
|
||||
* Impersonation cookies deliberately omit Max-Age so the browser treats
|
||||
* them as session cookies — the impersonated session ends when the user
|
||||
* closes the browser, not 30 days later. Impersonation is a temporary
|
||||
* support handoff; a normal password login is the only thing that should
|
||||
* survive a browser restart.
|
||||
*/
|
||||
function impersonationCookieOptions() {
|
||||
const { maxAge: _maxAge, ...rest } = getCookieOptions();
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/impersonate?token=<jwt>
|
||||
*
|
||||
* Master-user impersonation via signed JWT. The token carries the target
|
||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||
* master credentials from env, then mints the same session cookies the
|
||||
* password-login path produces. The browser is redirected to "/" and the
|
||||
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||
*
|
||||
* Returns 404 when the feature is not configured so an unconfigured
|
||||
* deployment does not advertise the endpoint.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readImpersonationConfig();
|
||||
if (!config) {
|
||||
// Not configured — behave exactly like an unknown route.
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
|
||||
}
|
||||
|
||||
let claims;
|
||||
try {
|
||||
claims = verifyImpersonationJwt(token, config.jwtSecret, {
|
||||
expectedIssuer: config.expectedIssuer,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ImpersonationJwtError) {
|
||||
logger.warn('Impersonation JWT rejected', { code: err.code });
|
||||
return NextResponse.json({ error: err.message }, { status: err.status });
|
||||
}
|
||||
logger.error('Impersonation JWT error', {
|
||||
error: err instanceof Error ? err.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
|
||||
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
|
||||
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
|
||||
}
|
||||
|
||||
const serverUrl = await resolveImpersonationServerUrl();
|
||||
if (!serverUrl) {
|
||||
logger.error('Impersonation requested but jmapServerUrl is not configured');
|
||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
let normalizedServerUrl: string;
|
||||
try {
|
||||
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Stalwart master-user impersonation: username = "<target>%<master>",
|
||||
// password = <master_password>. Per Stalwart docs:
|
||||
// https://stalw.art/docs/auth/authorization/administrator/
|
||||
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
|
||||
const authHeader = `Basic ${Buffer.from(
|
||||
`${impersonatedUsername}:${config.masterPassword}`,
|
||||
).toString('base64')}`;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = encryptSession(
|
||||
normalizedServerUrl,
|
||||
impersonatedUsername,
|
||||
config.masterPassword,
|
||||
);
|
||||
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username: impersonatedUsername,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
// Structured audit log — operators rely on this for security review.
|
||||
logger.info('Impersonation session granted', {
|
||||
event: 'impersonation_granted',
|
||||
jti: claims.jti,
|
||||
mailbox: claims.mailbox,
|
||||
tenant_id: claims.tenant_id,
|
||||
actor_user_id: claims.actor_user_id,
|
||||
iss: claims.iss,
|
||||
ip:
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
null,
|
||||
referer: request.headers.get('referer'),
|
||||
user_agent: request.headers.get('user-agent'),
|
||||
});
|
||||
|
||||
void recordLogin(impersonatedUsername, normalizedServerUrl);
|
||||
|
||||
// Use a relative Location header so the browser resolves it against the
|
||||
// public request URL. NextResponse.redirect(new URL('/', request.url))
|
||||
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
|
||||
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||
return new NextResponse(null, {
|
||||
status: 303,
|
||||
headers: { Location: '/' },
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
JmapAuthVerificationError,
|
||||
normalizeJmapServerUrl,
|
||||
validateProxyAuthHeader,
|
||||
verifyJmapAuth,
|
||||
} from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
@@ -15,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -74,10 +81,16 @@ export async function POST(request: NextRequest) {
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie
|
||||
// we write here is only ever consumed for requests on behalf of this same
|
||||
// user, so bogus credentials would just yield 401s downstream rather than
|
||||
// privilege escalation. Untrusted custom endpoints still verify upstream.
|
||||
const normalizedServerUrl = upstreamTrusted
|
||||
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
const token = encryptSession(normalizedServerUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
getRequiredConfig,
|
||||
getTokenEndpoint,
|
||||
} from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
@@ -56,6 +60,10 @@ export async function POST(request: NextRequest) {
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
const mobileRedirectUri =
|
||||
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
|
||||
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
|
||||
const isMobileFlow = Boolean(mobileRedirectUri);
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
@@ -65,21 +73,46 @@ export async function POST(request: NextRequest) {
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
// For the mobile handoff flow the tokens are handed back to the app
|
||||
// verbatim — we deliberately don't write any cookies on the webmail
|
||||
// origin (the mobile browser tab disposes of the session after the
|
||||
// redirect anyway, but the cookie would still get committed to the
|
||||
// user's main webmail session if they happened to be logged in there).
|
||||
if (!isMobileFlow) {
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
if (isMobileFlow) {
|
||||
// The mobile client needs the bits it can't re-derive: the refresh
|
||||
// token, the token endpoint it should hit to refresh later, and the
|
||||
// client_id the IdP expects on that refresh call. The server URL is
|
||||
// returned so the app knows which JMAP host to connect to.
|
||||
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
|
||||
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_endpoint: tokenEndpoint,
|
||||
client_id: clientId,
|
||||
server_url: serverUrl,
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
mobile_state: mobileState,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
|
||||
@@ -5,25 +5,48 @@ import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||
// this prefix may flow through the mobile handoff path; without the guard
|
||||
// the SSO complete route would be coerced into returning tokens to whatever
|
||||
// caller-controlled URL the attacker chose.
|
||||
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||
if (!hasSessionSecret()) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
|
||||
const {
|
||||
redirect_uri,
|
||||
locale,
|
||||
server_id: bodyServerId,
|
||||
mobile_redirect_uri: rawMobileRedirectUri,
|
||||
mobile_state: rawMobileState,
|
||||
} = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const mobileRedirectUri =
|
||||
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
|
||||
? rawMobileRedirectUri
|
||||
: null;
|
||||
const mobileState =
|
||||
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
@@ -39,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
@@ -52,12 +75,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||
// Mobile params are captured here so /complete knows to return tokens to
|
||||
// the caller (in the JSON response) instead of writing the usual server
|
||||
// cookies — and so the callback page can redirect back to the app.
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
@@ -73,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
||||
authUrl.searchParams.set('scope', getOauthScopes());
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
@@ -57,7 +57,22 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const slot = getSlot(request, bodySlot);
|
||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
|
||||
// still bind the cookie's `username` to the credential when we can verify
|
||||
// locally. Without this, a caller can POST username="admin@host" +
|
||||
// authHeader=<their own Basic creds>, and downstream consumers that read
|
||||
// the cookie-derived username (audit logs, login tracker) accept the
|
||||
// spoof. Bearer tokens are opaque so only the format check runs;
|
||||
// authorization sinks must key off the credential itself, not the
|
||||
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
|
||||
let normalizedServerUrl: string;
|
||||
if (upstreamTrusted) {
|
||||
validateProxyAuthHeader(authHeader);
|
||||
assertBasicAuthMatchesUsername(authHeader, username);
|
||||
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
|
||||
} else {
|
||||
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
}
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
|
||||
@@ -54,7 +54,7 @@ async function tryTokenRequest(
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl);
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -35,8 +36,9 @@ export async function GET() {
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
|
||||
@@ -107,15 +107,15 @@ const emails: MockEmail[] = [
|
||||
// =====================================================================
|
||||
{
|
||||
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Willkommen bei Bulwark Webmail!',
|
||||
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
|
||||
preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: {
|
||||
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
|
||||
p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -198,7 +198,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
|
||||
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Sprint planning - next week priorities',
|
||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||
hasAttachment: false,
|
||||
@@ -368,7 +368,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
@@ -472,7 +472,7 @@ const emails: MockEmail[] = [
|
||||
{
|
||||
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
|
||||
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
|
||||
subject: 'Design review feedback',
|
||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||
hasAttachment: false,
|
||||
@@ -486,7 +486,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||
hasAttachment: false,
|
||||
@@ -640,7 +640,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Conference talk accepted!',
|
||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||
@@ -729,8 +729,8 @@ const IDENTITIES = [
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
@@ -744,6 +744,12 @@ const addressBooks = [
|
||||
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
|
||||
];
|
||||
|
||||
// Profile photos served straight from randomuser.me's CDN; the API at
|
||||
// https://randomuser.me/api/ also returns these portrait URLs, but for a
|
||||
// fixed mock dataset we link them directly to keep things offline-friendly.
|
||||
// See https://randomuser.me/documentation#howto
|
||||
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||
|
||||
const contacts = [
|
||||
// --- Personal address book ---
|
||||
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
@@ -753,6 +759,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'EuroTech GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
|
||||
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||
@@ -761,6 +768,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||
@@ -769,6 +777,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||
@@ -776,6 +785,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+31 20 555 0142' } },
|
||||
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
|
||||
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||
@@ -784,6 +794,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
|
||||
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||
@@ -792,6 +803,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
|
||||
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||
@@ -799,6 +811,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
|
||||
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||
@@ -807,6 +820,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'BergLabs' } },
|
||||
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
|
||||
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||
@@ -815,6 +829,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
|
||||
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||
@@ -823,6 +838,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
|
||||
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Work address book ---
|
||||
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
@@ -832,6 +848,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
||||
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||
@@ -840,6 +857,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
|
||||
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||
@@ -848,6 +866,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
|
||||
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
||||
@@ -856,6 +875,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'García Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
|
||||
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||
@@ -864,6 +884,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Digitaal BV' } },
|
||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||
@@ -872,6 +893,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
|
||||
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||
@@ -880,6 +902,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
|
||||
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||
@@ -888,6 +911,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Ferreira Media' } },
|
||||
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
|
||||
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||
@@ -896,6 +920,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
|
||||
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
||||
@@ -905,6 +930,7 @@ const contacts = [
|
||||
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
||||
nicknames: { n1: { name: 'Anni' } },
|
||||
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Groups ---
|
||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||
@@ -977,7 +1003,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
},
|
||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||
@@ -987,7 +1013,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
},
|
||||
@@ -1025,7 +1051,7 @@ const calendarEvents = [
|
||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
},
|
||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||
@@ -1055,7 +1081,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
@@ -1067,7 +1093,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
|
||||
@@ -1085,7 +1111,7 @@ const calendarEvents = [
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
p5: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p5: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
|
||||
@@ -1094,7 +1120,7 @@ const calendarEvents = [
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p4: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p4: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
|
||||
@@ -1110,7 +1136,7 @@ const calendarEvents = [
|
||||
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
|
||||
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
|
||||
participants: {
|
||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
||||
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
@@ -1193,7 +1219,7 @@ const calendarEvents = [
|
||||
}),
|
||||
|
||||
// ===== Birthday calendar (cal-5) =====
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
|
||||
showWithoutTime: true,
|
||||
recurrence: [{ frequency: 'yearly' }],
|
||||
description: 'Don\'t forget to bring Kuchen!',
|
||||
@@ -1221,7 +1247,7 @@ const calendarEvents = [
|
||||
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let authHeader: string | null = null;
|
||||
if (parsed.username || parsed.password) {
|
||||
const username = decodeURIComponent(parsed.username);
|
||||
const password = decodeURIComponent(parsed.password);
|
||||
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
}
|
||||
|
||||
return { cleanUrl: parsed.toString(), authHeader };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await isPublicHttpUrl(url))) {
|
||||
const extracted = extractBasicAuth(url);
|
||||
if (!extracted) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { cleanUrl, authHeader } = extracted;
|
||||
|
||||
if (!(await isPublicHttpUrl(cleanUrl))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let currentUrl = cleanUrl;
|
||||
const originalOrigin = new URL(cleanUrl).origin;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
@@ -36,12 +64,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
};
|
||||
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
|
||||
*
|
||||
* Any logged-in user may query the server-side approval state for a plugin
|
||||
* they want to enable. The client uses this BEFORE running `enablePlugin`
|
||||
* when the `requirePluginApproval` policy is set.
|
||||
*
|
||||
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
|
||||
* pending approval entry (or returns the existing one).
|
||||
*/
|
||||
|
||||
async function resolveUsername(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (token) {
|
||||
const sess = decryptSession(token);
|
||||
if (sess?.username) return sess.username;
|
||||
}
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx?.username) return ctx.username;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
const status = await getApprovalStatus(pluginId, bundleHash);
|
||||
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
|
||||
const m = (b.manifest ?? {}) as Record<string, unknown>;
|
||||
const manifest: ApprovalEntry['manifest'] = {
|
||||
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
|
||||
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
|
||||
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
|
||||
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
|
||||
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
|
||||
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
};
|
||||
|
||||
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
|
||||
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-signing-pubkey
|
||||
*
|
||||
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
|
||||
* sandboxed plugin loader can verify bundle signatures before evaluation.
|
||||
* Public — every logged-in user needs to fetch it on app boot.
|
||||
*
|
||||
* The response is long-cache-eligible (the key rotates only when an operator
|
||||
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
|
||||
* client caches the result in memory for the lifetime of the page.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const publicKey = await getPublicKeyBase64();
|
||||
return NextResponse.json(
|
||||
{ algorithm: 'ed25519', publicKey },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export async function GET() {
|
||||
dev: p.dev,
|
||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||
httpOrigins: p.httpOrigins,
|
||||
// Surface so clients can enforce api.http.post path allowlists.
|
||||
apiPostPaths: p.apiPostPaths,
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import {
|
||||
getStalwartCredentials,
|
||||
type StalwartCredentials,
|
||||
} from '@/lib/stalwart/credentials';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ResolvedTarget {
|
||||
authHeader: string;
|
||||
apiUrl: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
|
||||
// that account - not just "the first signed-in slot", which is what
|
||||
// getStalwartCredentials() defaults to. Probe each candidate's session in
|
||||
// parallel and return the first match.
|
||||
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
|
||||
const cookieStore = await cookies();
|
||||
const probes: Promise<ResolvedTarget | null>[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (!ctx) continue;
|
||||
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
|
||||
probes.push(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: ctx.authHeader },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const session = (await res.json()) as {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
};
|
||||
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!session.apiUrl || !mailAccountId) return null;
|
||||
if (mailAccountId !== accountId) return null;
|
||||
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
const results = await Promise.all(probes);
|
||||
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
|
||||
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: creds.authHeader },
|
||||
});
|
||||
if (!sessionRes.ok) return null;
|
||||
const session = (await sessionRes.json()) as {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
};
|
||||
const apiUrl = session.apiUrl;
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!apiUrl || !accountId) return null;
|
||||
return { authHeader: creds.authHeader, apiUrl, accountId };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/push/preview
|
||||
*
|
||||
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
|
||||
// StateChange so multi-account browsers fetch from the right slot. Older
|
||||
// clients (and the manual /api/push/preview probe) omit it and fall back
|
||||
// to the first signed-in slot.
|
||||
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
|
||||
|
||||
let target: ResolvedTarget | null = null;
|
||||
let authHeader: string;
|
||||
if (requestedAccountId) {
|
||||
target = await resolveTargetForAccount(requestedAccountId);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
authHeader = target.authHeader;
|
||||
} else {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
target = await resolveDefaultTarget(creds);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||
}
|
||||
authHeader = creds.authHeader;
|
||||
}
|
||||
|
||||
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: creds.authHeader },
|
||||
});
|
||||
if (!sessionRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||
}
|
||||
const session = (await sessionRes.json()) as {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
};
|
||||
const apiUrl = session.apiUrl;
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!apiUrl || !accountId) {
|
||||
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||
}
|
||||
const { apiUrl, accountId } = target;
|
||||
|
||||
const inboxRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
|
||||
const jmapRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
function classifyError(error: unknown): { message: string; status: number } {
|
||||
@@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||
const flagOn =
|
||||
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
|
||||
configManager.get<boolean>('settingsSyncEnabled', false);
|
||||
return flagOn && hasSessionSecret();
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/setup/branding - wizard branding upload.
|
||||
*
|
||||
* Multipart form fields:
|
||||
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||
*
|
||||
* Mirrors /api/admin/branding but authenticates via the wizard cookie
|
||||
* instead of admin session - admin auth doesn't exist yet during bootstrap.
|
||||
* Files land in the same directory; the public read endpoint at
|
||||
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
||||
* assets after setup.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertWritable('upload branding asset');
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
const slot = formData.get('slot');
|
||||
|
||||
if (!(file instanceof File) || typeof slot !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
|
||||
const dir = getBrandingDir();
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove any existing file for this slot with a different extension so
|
||||
// the wizard doesn't leave orphan files behind on re-upload.
|
||||
for (const otherExt of Object.values(EXT_BY_MIME)) {
|
||||
if (otherExt === ext) continue;
|
||||
const oldPath = path.join(dir, `${slot}${otherExt}`);
|
||||
if (existsSync(oldPath)) {
|
||||
try { await unlink(oldPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filePath = path.join(dir, safeName);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
logger.error('Wizard branding upload failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/setup/branding - remove an uploaded asset and clear the
|
||||
* config override so the slot falls back to the system default.
|
||||
*
|
||||
* Body: { slot: string }
|
||||
*/
|
||||
export async function DELETE(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 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertWritable('remove branding asset');
|
||||
const { slot } = (await request.json()) as { slot?: string };
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getBrandingDir();
|
||||
for (const ext of Object.values(EXT_BY_MIME)) {
|
||||
const filePath = path.join(dir, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
try { await unlink(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Wizard branding delete failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { setInitialAdminPassword } from '@/lib/admin/password';
|
||||
import { clearSetupToken } from '@/lib/setup/token';
|
||||
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/setup/finish
|
||||
*
|
||||
* Final wizard step. Validates that required config is in place, hashes the
|
||||
* admin password, marks setup complete, deletes the setup token (which
|
||||
* invalidates the wizard cookie), and optionally drops a `.config-locked`
|
||||
* marker so the operator remembers they intended to mount :ro.
|
||||
*
|
||||
* Body: { adminPassword: string, lockConfig?: boolean }
|
||||
*/
|
||||
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: { adminPassword?: unknown; lockConfig?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminPassword =
|
||||
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
|
||||
if (adminPassword.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin password must be at least 8 characters' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const lockConfig = body?.lockConfig === true;
|
||||
|
||||
// Validate required config is present.
|
||||
await configManager.ensureLoaded();
|
||||
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
|
||||
if (!jmapUrl || typeof jmapUrl !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'JMAP server URL is required (run the Server step first)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Provision the admin account. An admin.json file may already exist
|
||||
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
|
||||
// run while setupComplete is still false — accept the wizard's
|
||||
// password as authoritative in that case. The finish route is gated
|
||||
// by the bootstrap state + one-time setup token, so this is safe.
|
||||
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
|
||||
if (!created) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to write admin credentials' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Persist setupComplete flag. After this, detectSetupState() flips
|
||||
// to 'configured' and middleware starts 404'ing /setup paths.
|
||||
await configManager.markSetupComplete();
|
||||
|
||||
// 3. Optional advisory lock marker.
|
||||
if (lockConfig) {
|
||||
await ensureConfigDir();
|
||||
await writeFile(
|
||||
getConfigPath('.config-locked'),
|
||||
new Date().toISOString(),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Destroy the setup token. Any other browser holding the cookie is
|
||||
// now unauthenticated.
|
||||
await clearSetupToken();
|
||||
|
||||
await auditLog(
|
||||
'setup.finish',
|
||||
{ lockConfig, jmapServerUrl: jmapUrl },
|
||||
request.headers.get('x-forwarded-for') ?? 'unknown',
|
||||
);
|
||||
|
||||
const response = NextResponse.json({ ok: true, lockConfig });
|
||||
response.cookies.delete(SETUP_COOKIE);
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error('Wizard finish failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isConfigReadOnly } from '@/lib/admin/paths';
|
||||
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/setup/status - public endpoint that returns the wizard state
|
||||
* and (if authenticated) the partial config saved by previous steps. The
|
||||
* wizard polls this on load so a refresh resumes with prior values.
|
||||
*
|
||||
* Sensitive values (OAuth client secret, session secret) are NEVER sent
|
||||
* back to the client - only a `<key>HasValue` boolean. Re-entering them
|
||||
* after refresh is the price of not exposing them.
|
||||
*/
|
||||
export async function GET() {
|
||||
await configManager.ensureLoaded();
|
||||
const state = detectSetupState();
|
||||
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
|
||||
|
||||
let partialConfig: Record<string, unknown> | null = null;
|
||||
if (state === 'bootstrap' && authenticated) {
|
||||
// Only echo back values the operator has actually saved during the
|
||||
// wizard (admin overrides). System defaults must not flow back here,
|
||||
// because the wizard has its own opinionated defaults (e.g. settings
|
||||
// sync on by default) that we'd otherwise stomp.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const [key, info] of Object.entries(sources)) {
|
||||
if (info.source !== 'admin') continue;
|
||||
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
|
||||
} else {
|
||||
safe[key] = info.value;
|
||||
}
|
||||
}
|
||||
partialConfig = safe;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
state,
|
||||
authenticated,
|
||||
readOnly: isConfigReadOnly(),
|
||||
partialConfig,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Mapping of wizard-friendly step keys to the config keys they update. Each
|
||||
* step's PATCH validates against this allowlist so a compromised wizard
|
||||
* client can't slip in arbitrary config keys.
|
||||
*/
|
||||
const STEP_KEYS: Record<string, string[]> = {
|
||||
server: [
|
||||
'appName',
|
||||
'jmapServerUrl',
|
||||
'stalwartFeaturesEnabled',
|
||||
'jmapServers',
|
||||
'jmapServerAutoPickByDomain',
|
||||
],
|
||||
auth: [
|
||||
'oauthEnabled',
|
||||
'oauthOnly',
|
||||
'oauthClientId',
|
||||
'oauthClientSecret',
|
||||
'oauthIssuerUrl',
|
||||
],
|
||||
security: ['sessionSecret', 'settingsSyncEnabled'],
|
||||
logging: ['logFormat', 'logLevel'],
|
||||
branding: [
|
||||
'faviconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'loginCompanyName',
|
||||
'loginImprintUrl',
|
||||
'loginPrivacyPolicyUrl',
|
||||
'loginWebsiteUrl',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/setup/step
|
||||
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
|
||||
*
|
||||
* Persists partial config under the admin override (config.json). Each
|
||||
* step's allowed keys are restricted by STEP_KEYS so the client can only
|
||||
* touch what the corresponding screen owns.
|
||||
*/
|
||||
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: { step?: unknown; values?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const step = typeof body?.step === 'string' ? body.step : '';
|
||||
const values = body?.values;
|
||||
const allowedKeys = STEP_KEYS[step];
|
||||
if (!allowedKeys) {
|
||||
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
|
||||
}
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
||||
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
||||
}
|
||||
if (!(key in CONFIG_ENV_MAP)) {
|
||||
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||
}
|
||||
if (key === 'jmapServers') {
|
||||
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
|
||||
// before they're persisted. Mirrors the admin config PATCH route.
|
||||
if (value != null && !Array.isArray(value)) {
|
||||
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseJmapServers(value);
|
||||
const incomingCount = Array.isArray(value) ? value.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json(
|
||||
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
updates[key] = sanitized;
|
||||
continue;
|
||||
}
|
||||
updates[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig(updates);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Wizard step save failed', {
|
||||
step,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { verifySetupToken } from '@/lib/setup/token';
|
||||
import { buildSessionCookieAttributes } from '@/lib/setup/session';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
|
||||
* startup) for a wizard session cookie. After this, subsequent step calls
|
||||
* authenticate via the cookie instead of pasting the token every time.
|
||||
*
|
||||
* Body: { token: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: { token?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
|
||||
if (!submitted) {
|
||||
return NextResponse.json({ error: 'Token required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await verifySetupToken(submitted);
|
||||
if (!ok) {
|
||||
// Don't differentiate between "wrong token" and "no token issued" - the
|
||||
// operator either has it from the logs or they don't.
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const attrs = buildSessionCookieAttributes();
|
||||
response.cookies.set(attrs.name, submitted, {
|
||||
httpOnly: attrs.httpOnly,
|
||||
sameSite: attrs.sameSite,
|
||||
secure: attrs.secure,
|
||||
path: attrs.path,
|
||||
maxAge: attrs.maxAge,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user