feat: P2.9 VNCtalk + P2.10 Collabora + P2.11 Calendar Enhancements + P2.13 VNCdirectory Admin
- 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.
This commit is contained in:
+6
-1
@@ -236,10 +236,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
vnctalkServerUrl: { envVar: 'VNCTALK_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectoryEnabled: { envVar: 'VNCDIRECTORY_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectorySamlEnabled: { envVar: 'VNCDIRECTORY_SAML_ENABLED', type: 'boolean', defaultValue: false },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret']);
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapPassword']);
|
||||
|
||||
/** Admin session cookie name */
|
||||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface VncDirectoryConfig {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
samlEnabled: boolean;
|
||||
samlIdpUrl: string;
|
||||
samlSpCert: string;
|
||||
samlIssuer: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUri: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapSearchBase: string;
|
||||
ldapType: 'openldap' | 'ms-ad';
|
||||
tfaEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcClientId: string;
|
||||
oidcDiscoveryUrl: string;
|
||||
sessionTtl: number;
|
||||
federatedApps: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_VNCDIRECTORY_CONFIG: VncDirectoryConfig = {
|
||||
enabled: false,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
samlEnabled: false,
|
||||
samlIdpUrl: '',
|
||||
samlSpCert: '',
|
||||
samlIssuer: '',
|
||||
ldapEnabled: false,
|
||||
ldapUri: '',
|
||||
ldapBindDn: '',
|
||||
ldapBindPassword: '',
|
||||
ldapSearchBase: '',
|
||||
ldapType: 'openldap',
|
||||
tfaEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcClientId: '',
|
||||
oidcDiscoveryUrl: '',
|
||||
sessionTtl: 28800,
|
||||
federatedApps: {},
|
||||
};
|
||||
|
||||
/** Keys that should be masked when returning config to clients */
|
||||
export const VNCDIRECTORY_SENSITIVE_KEYS = new Set(['apiKey', 'ldapBindPassword']);
|
||||
|
||||
function applyEnvOverrides(config: VncDirectoryConfig): VncDirectoryConfig {
|
||||
const envEnabled = process.env.VNCDIRECTORY_ENABLED;
|
||||
if (envEnabled !== undefined) {
|
||||
config.enabled = envEnabled === 'true' || envEnabled === '1';
|
||||
}
|
||||
const envApiUrl = process.env.VNCDIRECTORY_API_URL;
|
||||
if (envApiUrl !== undefined) {
|
||||
config.apiUrl = envApiUrl;
|
||||
}
|
||||
const envSamlEnabled = process.env.VNCDIRECTORY_SAML_ENABLED;
|
||||
if (envSamlEnabled !== undefined) {
|
||||
config.samlEnabled = envSamlEnabled === 'true' || envSamlEnabled === '1';
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
||||
const filePath = getStatePath(filename);
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
logger.warn(`Failed to read ${filename} from state dir`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
||||
await ensureStateDir();
|
||||
const targetPath = getStatePath(filename);
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
await rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function getVncDirectoryConfig(): Promise<VncDirectoryConfig> {
|
||||
const fileConfig = await readJsonFile('vncdirectory.json');
|
||||
const base = fileConfig
|
||||
? { ...DEFAULT_VNCDIRECTORY_CONFIG, ...fileConfig }
|
||||
: { ...DEFAULT_VNCDIRECTORY_CONFIG };
|
||||
return applyEnvOverrides(base);
|
||||
}
|
||||
|
||||
export async function saveVncDirectoryConfig(
|
||||
config: Partial<VncDirectoryConfig>,
|
||||
): Promise<void> {
|
||||
const current = await getVncDirectoryConfig();
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(DEFAULT_VNCDIRECTORY_CONFIG)) {
|
||||
const k = key as keyof VncDirectoryConfig;
|
||||
if (k in config) {
|
||||
merged[key] = config[k];
|
||||
} else {
|
||||
merged[key] = current[k];
|
||||
}
|
||||
}
|
||||
await writeJsonFile('vncdirectory.json', merged);
|
||||
}
|
||||
|
||||
export async function isVncDirectoryEnabled(): Promise<boolean> {
|
||||
const cfg = await getVncDirectoryConfig();
|
||||
return cfg.enabled;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export async function getCollaboraEditUrl(
|
||||
fileId: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const serverUrl =
|
||||
configManager.get<string>("collaboraServerUrl") ||
|
||||
process.env.COLLABORA_SERVER_URL ||
|
||||
"";
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error("COLLABORA_SERVER_URL is not configured");
|
||||
}
|
||||
|
||||
const base = serverUrl.replace(/\/+$/, "");
|
||||
const fileExt = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
// Collabora WOPI host discovery endpoint
|
||||
const response = await fetch(`${base}/hosting/discovery`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Collabora discovery failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const discovery = await response.json();
|
||||
|
||||
// Find the WOPI action URL for the file extension
|
||||
let actionUrl: string | null = null;
|
||||
const mimeMap: Record<string, string> = {
|
||||
docx: "text",
|
||||
doc: "text",
|
||||
odt: "text",
|
||||
xlsx: "spreadsheet",
|
||||
xls: "spreadsheet",
|
||||
ods: "spreadsheet",
|
||||
pptx: "presentation",
|
||||
ppt: "presentation",
|
||||
odp: "presentation",
|
||||
};
|
||||
const docType = mimeMap[fileExt] || "text";
|
||||
|
||||
if (discovery.net?.zone) {
|
||||
const zones = Array.isArray(discovery.net.zone)
|
||||
? discovery.net.zone
|
||||
: [discovery.net.zone];
|
||||
for (const zone of zones) {
|
||||
const apps = Array.isArray(zone.app) ? zone.app : zone.app ? [zone.app] : [];
|
||||
for (const app of apps) {
|
||||
if (
|
||||
app.name &&
|
||||
docType &&
|
||||
app.name.toLowerCase().includes(docType.toLowerCase())
|
||||
) {
|
||||
const actions = Array.isArray(app.action)
|
||||
? app.action
|
||||
: app.action
|
||||
? [app.action]
|
||||
: [];
|
||||
for (const action of actions) {
|
||||
if (action.name === "edit" && action.urlsrc) {
|
||||
actionUrl = action.urlsrc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (actionUrl) break;
|
||||
}
|
||||
if (actionUrl) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionUrl) {
|
||||
// Fallback: construct URL manually
|
||||
actionUrl = `${base}/loleaflet/dist/loleaflet.html`;
|
||||
}
|
||||
|
||||
// For now, return the base edit URL. A full WOPI implementation would
|
||||
// generate a WOPI src URL with an access token pointing back to this server.
|
||||
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
)}`;
|
||||
|
||||
return wopiSrcUrl;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export interface CreateVncMeetingParams {
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
invitees: string[];
|
||||
password?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateVncMeetingResult {
|
||||
meetingUrl: string;
|
||||
meetingId: string;
|
||||
}
|
||||
|
||||
export async function createVncMeeting(
|
||||
params: CreateVncMeetingParams
|
||||
): Promise<CreateVncMeetingResult> {
|
||||
const serverUrl = configManager.get<string>("vnctalkServerUrl") || process.env.VNCTALK_SERVER_URL || "";
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error("VNCTALK_SERVER_URL is not configured");
|
||||
}
|
||||
|
||||
const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/createnewmeeting`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
invitees: params.invitees,
|
||||
password: params.password,
|
||||
description: params.description,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`VNCtalk API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const meetingUrl: string = data.meetingUrl || data.meeting_url || data.url || "";
|
||||
const meetingId: string = data.meetingId || data.meeting_id || data.id || "";
|
||||
|
||||
if (!meetingUrl) {
|
||||
throw new Error("VNCtalk API did not return a meeting URL");
|
||||
}
|
||||
|
||||
return { meetingUrl, meetingId };
|
||||
}
|
||||
Reference in New Issue
Block a user