- P2.9: VNCtalk video meeting — create/update meeting from event modal, 'Join Meeting' link in event detail. Admin config vnctalkServerUrl. - P2.10: Collabora online editing — 'Edit with Collabora' for office files, WOPI discovery + edit URL. Admin config collaboraServerUrl. - P2.11: Calendar enhancements — clickable links in descriptions, participant contact popover, Reply/Reply All from event, timezone picker, map links for locations. - P2.13: VNCdirectory IDP admin panel — Connection, SAML/IDP, LDAP, Authentication, Federated Apps configuration. Secret masking on display.
147 lines
4.9 KiB
TypeScript
147 lines
4.9 KiB
TypeScript
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 {
|
|
getVncDirectoryConfig,
|
|
saveVncDirectoryConfig,
|
|
DEFAULT_VNCDIRECTORY_CONFIG,
|
|
VNCDIRECTORY_SENSITIVE_KEYS,
|
|
type VncDirectoryConfig,
|
|
} from '@/lib/admin/vncdirectory-config';
|
|
|
|
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
|
|
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
|
|
|
|
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
|
|
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
const config = await getVncDirectoryConfig();
|
|
return NextResponse.json(maskConfigForClient(config), {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
});
|
|
} catch (error) {
|
|
logger.error('VNCdirectory config read error', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const authResult = await requireAdminAuth(request);
|
|
if ('error' in authResult) return authResult.error;
|
|
|
|
const ip = getClientIP(request);
|
|
const body = await request.json();
|
|
|
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
|
}
|
|
|
|
// Validate known keys only
|
|
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
|
|
if (unknownKeys.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Validate boolean fields
|
|
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
|
|
for (const key of boolFields) {
|
|
if (key in body && typeof body[key] !== 'boolean') {
|
|
return NextResponse.json(
|
|
{ error: `${key} must be a boolean` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Validate sessionTtl
|
|
if ('sessionTtl' in body) {
|
|
const ttl = Number(body.sessionTtl);
|
|
if (!Number.isFinite(ttl) || ttl < 0) {
|
|
return NextResponse.json(
|
|
{ error: 'sessionTtl must be a non-negative number' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
body.sessionTtl = ttl;
|
|
}
|
|
|
|
// Validate ldapType
|
|
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
|
|
return NextResponse.json(
|
|
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Validate federatedApps
|
|
if ('federatedApps' in body) {
|
|
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
|
|
return NextResponse.json(
|
|
{ error: 'federatedApps must be an object mapping app names to URLs' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
|
|
if (typeof url !== 'string') {
|
|
return NextResponse.json(
|
|
{ error: `federatedApps.${appName} must be a string URL` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If apiKey or ldapBindPassword are "••••••", preserve existing value
|
|
const currentConfig = await getVncDirectoryConfig();
|
|
if (body.apiKey === '••••••') {
|
|
body.apiKey = currentConfig.apiKey;
|
|
}
|
|
if (body.ldapBindPassword === '••••••') {
|
|
body.ldapBindPassword = currentConfig.ldapBindPassword;
|
|
}
|
|
|
|
const changedKeys = Object.keys(body).filter((k) => {
|
|
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
|
|
const newVal = body[k];
|
|
if (k === 'federatedApps') {
|
|
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
|
|
}
|
|
return String(currentVal ?? '') !== String(newVal ?? '');
|
|
});
|
|
|
|
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
|
|
|
|
if (changedKeys.length > 0) {
|
|
await auditLog('vncdirectory.update', { changedKeys }, ip);
|
|
}
|
|
|
|
return NextResponse.json({ ok: true });
|
|
} catch (error) {
|
|
logger.error('VNCdirectory config update error', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|