mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
109 lines
5.2 KiB
TypeScript
109 lines
5.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
|
|
import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
|
|
import {
|
|
VaultFullError,
|
|
deleteUserCredentials,
|
|
listUserCredentials,
|
|
putUserCredential,
|
|
type VaultUser,
|
|
} from '@/lib/plugin-secrets/vault';
|
|
|
|
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
const MAX_SECRET_LENGTH = 4096;
|
|
const NO_STORE = { 'cache-control': 'no-store' };
|
|
|
|
async function currentUser(request: NextRequest): Promise<VaultUser | NextResponse> {
|
|
await configManager.ensureLoaded();
|
|
if (!configManager.getPolicy().features.pluginsEnabled) {
|
|
return NextResponse.json({ error: 'Plugins are turned off' }, { status: 403 });
|
|
}
|
|
const creds = await getStalwartCredentials(request);
|
|
if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
return { username: creds.username, serverUrl: creds.serverUrl };
|
|
}
|
|
|
|
/** GET /api/plugin-secrets[?plugin=<id>] - the user's connected accounts, without secrets. */
|
|
export async function GET(request: NextRequest) {
|
|
const user = await currentUser(request);
|
|
if (user instanceof NextResponse) return user;
|
|
const pluginId = request.nextUrl.searchParams.get('plugin') ?? undefined;
|
|
if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) {
|
|
return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
|
|
}
|
|
const credentials = await listUserCredentials(user, pluginId);
|
|
// For one plugin, also say which of its admin credentials are set up, and
|
|
// on which servers: the host page checks the links a plugin shows against
|
|
// them (the plugin itself only gets yes/no, see proxyAccounts).
|
|
const [admin, adminOrigins] = pluginId
|
|
? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
|
|
: [[], []];
|
|
return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
|
}
|
|
|
|
/**
|
|
* POST /api/plugin-secrets - connect an account for a plugin.
|
|
* Body: { pluginId, credentialId, label, origin, inject, username?, secret? }
|
|
*
|
|
* Sent by the host's own "Connect" dialog, where the user typed the secret
|
|
* and saw which plugin and which server it is for. Without `secret` it only
|
|
* records the approved server, for a sign-in flow that fills the secret in
|
|
* later through the proxy.
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
const crossOrigin = rejectCrossOriginRequest(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
const user = await currentUser(request);
|
|
if (user instanceof NextResponse) return user;
|
|
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
}
|
|
const { pluginId, credentialId, label, username, secret } = body;
|
|
const origin = credentialOrigin(body.origin);
|
|
const inject = checkInject(body.inject);
|
|
if (typeof pluginId !== 'string' || !PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
|
|
if (typeof credentialId !== 'string' || !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
|
|
if (typeof label !== 'string' || !label.trim() || label.length > 80) return bad('Invalid label');
|
|
if (!origin) return bad('The server address must start with https://');
|
|
if (!inject) return bad('Invalid inject');
|
|
if (username !== undefined && (typeof username !== 'string' || username.length > 256 || /[\r\n]/.test(username))) return bad('Invalid username');
|
|
if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || /[\r\n]/.test(secret))) return bad('Invalid secret');
|
|
|
|
try {
|
|
await putUserCredential(user, pluginId, credentialId, {
|
|
origin, inject, label: label.trim(),
|
|
...(typeof username === 'string' && username ? { username } : {}),
|
|
...(typeof secret === 'string' ? { secret } : {}),
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof VaultFullError) return NextResponse.json({ error: error.message }, { status: 409 });
|
|
throw error;
|
|
}
|
|
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
|
}
|
|
|
|
/** DELETE /api/plugin-secrets?plugin=<id>[&credential=<id>] - disconnect. */
|
|
export async function DELETE(request: NextRequest) {
|
|
const crossOrigin = rejectCrossOriginRequest(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
const user = await currentUser(request);
|
|
if (user instanceof NextResponse) return user;
|
|
const pluginId = request.nextUrl.searchParams.get('plugin') ?? '';
|
|
const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined;
|
|
if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
|
|
if (credentialId !== undefined && !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
|
|
await deleteUserCredentials(user, pluginId, credentialId);
|
|
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
|
}
|
|
|
|
function bad(error: string) {
|
|
return NextResponse.json({ error }, { status: 400 });
|
|
}
|