Files
selfphoneng/apps/api/src/routes/settings.ts
T
iamdoubz 692dd59552
ci / check (push) Successful in 1m0s
ci / images (push) Successful in 32s
contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard
Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token
write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings,
books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in
Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack
(2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake
and a real Contrack 2.0.2 (contrack-live.test.ts).
2026-10-23 15:23:49 -05:00

323 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
import { eq, inArray } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
type Destination,
type SettingKey,
type SettingsDto,
type Warning,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
import { notifyEmergency } from '../services/emergency.ts';
import { readTlsCert } from '../services/tls.ts';
import { backupMissed } from '../services/backup.ts';
import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
declare module 'fastify' {
interface FastifyInstance {
/** Decorated by the caller (index.ts: SMTP; tests: a fake). */
sendMail: SendMail;
}
}
/** Set by the system itself, never through PATCH. */
const READ_ONLY = new Set<string>([
'system.restartRequired',
'system.transportHash',
'sms.webhookToken',
'setup.completed',
]);
async function toDto(db: Db): Promise<SettingsDto> {
const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
return {
...rest,
'contacts.contrack': ctk && {
url: ctk.url,
publicUrl: ctk.publicUrl,
hasCompanyToken: ctk.companyTokenEnc !== '',
},
smtp: smtp && {
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
username: smtp.username,
from: smtp.from,
hasPassword: smtp.passwordEnc !== '',
},
};
}
/** References inside settings must point at rows that exist; transports in use stay on. */
async function checkRefs(db: Db, v: Record<string, unknown>) {
for (const key of ['routing.defaultCallerIdDidId', 'emergency.defaultCallerIdDidId'] as const) {
const id = v[key] as string | null | undefined;
if (!id) continue;
const [d] = await db.select({ id: dids.id }).from(dids).where(eq(dids.id, id));
if (!d) throw badRequest(`${key}: that phone number does not exist`);
}
const ids = v['emergency.notifyExtensionIds'] as string[] | undefined;
if (ids?.length) {
const found = await db
.select({ id: extensions.id })
.from(extensions)
.where(inArray(extensions.id, ids));
if (found.length !== new Set(ids).size)
throw badRequest('emergency.notifyExtensionIds: one of those extensions does not exist');
}
if (v['routing.unmatchedDidDestination'])
await assertDestinationExists(db, v['routing.unmatchedDidDestination'] as Destination);
for (const [key, transport] of [
['sip.tcpEnabled', 'tcp'],
['sip.tlsEnabled', 'tls'],
] as const)
if (v[key] === false) {
const [t] = await db
.select({ name: trunks.name })
.from(trunks)
.where(eq(trunks.transport, transport));
if (t) throw badRequest(`Trunk ${t.name} uses ${transport.toUpperCase()}: change it first`);
}
}
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
app.get('/api/settings', () => toDto(db));
app.patch('/api/settings', async (req) => {
const body = z.record(z.string(), z.unknown()).parse(req.body);
const values: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(body)) {
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
throw badRequest(`${key} can't be changed here`);
if (key === 'smtp') {
// The password is write-only: empty keeps the stored one.
const input = SmtpInputSchema.nullable().parse(raw);
const old = await getSetting(db, 'smtp');
values.smtp = input && {
host: input.host,
port: input.port,
secure: input.secure,
username: input.username,
from: input.from,
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
};
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
const input = ContrackInputSchema.nullable().parse(raw);
const old = await getSetting(db, 'contacts.contrack');
if (input)
try {
assertLocalUrl(input.url);
} catch (err) {
throw badRequest(`Contrack address: ${(err as Error).message}`);
}
values[key] = input && {
url: input.url.replace(/\/+$/, ''),
publicUrl: input.publicUrl,
companyTokenEnc: input.companyToken
? encrypt(input.companyToken)
: input.clearCompanyToken
? ''
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
}
await checkRefs(db, values);
for (const key of ['ai.llm', 'ai.stt', 'ai.tts'] as const) {
const ep = values[key] as { url: string } | null | undefined;
try {
if (ep) assertLocalUrl(ep.url);
} catch (err) {
if (err instanceof AiError) throw badRequest(`${key}: ${err.message}`);
throw err;
}
}
const before = await getAllSettings(db);
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
const c = app.config;
const [sip, tls] = [
(values['sip.udpPort'] ?? before['sip.udpPort']) as number,
(values['sip.tlsPort'] ?? before['sip.tlsPort']) as number,
];
for (const port of [sip, tls])
if ([80, 443, 8088, c.AMI_PORT, c.API_PORT].includes(port))
throw badRequest(`Port ${port} is already used by SelfPhoneNG`);
else if (port >= c.RTP_PORT_START && port <= c.RTP_PORT_END)
throw badRequest(
`Port ${port} is in the call audio range (${c.RTP_PORT_START}–${c.RTP_PORT_END})`,
);
if (sip === tls) throw badRequest('The SIP port and the TLS port must differ');
if (values['sip.tlsEnabled'] === true && !before['sip.tlsEnabled']) {
const cert = await readTlsCert(app.config.TLS_DIR);
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
}
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
const changed = Object.keys(values).filter(
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
);
if (changed.length) {
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'settings',
summary: `Changed settings: ${changed.join(', ')}`,
diff: {
before: Object.fromEntries(changed.map((k) => [k, before[k as keyof typeof before]])),
after: Object.fromEntries(changed.map((k) => [k, values[k]])),
},
});
app.generator.requestApply('api-change');
}
return toDto(db);
});
app.post('/api/settings/smtp/test', async (req) => {
const { to } = z.object({ to: z.email() }).parse(req.body);
try {
await app.sendMail({
to: [to],
subject: 'SelfPhoneNG test email',
text: 'Email from your phone system works.',
});
} catch (e) {
throw badRequest(`Sending failed: ${(e as Error).message}`);
}
return { ok: true };
});
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
app.post('/api/settings/emergency/test', async (req) => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
const notified = await notifyEmergency(
{ db, ami: app.ami, live: app.live, send: app.sendMail, log: req.log },
{
ext: ext?.number ?? '',
name: req.user!.displayName,
callerId: 'TEST',
dialed: '911',
test: true,
},
);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'emergency_test',
summary: 'Tested the emergency notifications (nothing was dialed)',
diff: { after: notified },
});
return { notified };
});
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', async () => {
const ip = await detectPublicIp();
if (ip) return { ip };
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
});
// First-run wizard (05 §2.1).
app.get('/api/setup/status', async () => ({
completed: await getSetting(db, 'setup.completed'),
}));
app.post('/api/setup/complete', async (req) => {
await setSetting(db, 'setup.completed', true);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'setup_complete',
summary: 'Finished the setup wizard',
});
return { ok: true };
});
/** Settings → AI → Test (spec 10): the values in the form, saved or not. */
app.post('/api/ai/test', async (req): Promise<AiTestResult> => {
const { kind } = z.object({ kind: z.enum(['llm', 'stt', 'tts']) }).parse(req.body);
const ep = (kind === 'tts' ? AiTtsEndpointSchema : AiEndpointSchema).parse(
(req.body as { endpoint?: unknown }).endpoint,
);
return testEndpoint(kind, ep);
});
/** The SIP TLS certificate, for Settings → Security (M11-T2). */
app.get('/api/system/tls', () => readTlsCert(app.config.TLS_DIR));
/** Things an admin should fix, for the dashboard banner (05 §2). */
app.get('/api/system/warnings', async (): Promise<{ items: Warning[] }> => {
const items: Warning[] = [];
const s = await getAllSettings(db);
if (s['system.restartRequired'])
items.push({
code: 'restart_required',
message: 'SIP port or transport changes need a telephony restart.',
link: '/admin/system',
});
const [anyTrunk] = await db.select({ id: trunks.id }).from(trunks).limit(1);
const e911 = s['emergency.defaultCallerIdDidId'];
if (anyTrunk && !e911)
items.push({
code: 'e911_cid_missing',
message: 'No emergency (E911) caller ID is set. 911 calls may not carry your address.',
link: '/admin/settings/emergency',
});
if (e911) {
const [d] = await db.select().from(dids).where(eq(dids.id, e911));
if (d && !d.e911Address)
items.push({
code: 'e911_address_unconfirmed',
message: 'Confirm the emergency caller ID number has an E911 address at voip.ms.',
link: '/admin/numbers',
});
}
if (s['sip.tlsEnabled']) {
const cert = await readTlsCert(app.config.TLS_DIR);
const days = cert.ok ? (Date.parse(cert.notAfter) - Date.now()) / 86_400_000 : 0;
if (days < 14)
items.push({
code: 'tls_cert',
message: cert.ok
? `The SIP TLS certificate ${days < 0 ? 'has expired' : `expires in ${Math.ceil(days)} days`}. Run scripts/tls-cert.sh after renewing it.`
: `SIP over TLS is on, but: ${cert.error}.`,
link: '/admin/settings/security',
});
}
if (await backupMissed(app.config, db))
items.push({
code: 'backup_old',
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
link: '/admin/settings/backups',
});
if (!s.smtp)
items.push({
code: 'smtp_missing',
message: 'Email is not set up, so emergency and voicemail emails can’t be sent.',
link: '/admin/settings/email',
});
return { items };
});
}