Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb6d52ddf | ||
|
|
7722f33639 | ||
|
|
97e67e347d | ||
|
|
19610777a4 | ||
|
|
b06335586b | ||
|
|
73963ee3a3 | ||
|
|
06b3a73edc | ||
|
|
3a9423af1f | ||
|
|
ba293f8703 | ||
|
|
95561f651f | ||
|
|
53aefaa5d7 | ||
|
|
604e40adf1 | ||
|
|
e6a1373f36 | ||
|
|
b05361e840 | ||
|
|
692dd59552 | ||
|
|
a754974a90 | ||
|
|
32f4590369 | ||
|
|
20d27232ac | ||
|
|
4c006f2388 | ||
|
|
46cc8c2002 |
No files matched your search
@@ -7,6 +7,7 @@ INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet
|
||||
|
||||
# --- optional ---
|
||||
CONTRACK_HOSTNAME= # Contrack contacts (spec 11 §4): its own DNS name, e.g. contacts.example.com
|
||||
TZ=America/Chicago
|
||||
API_PORT=8080
|
||||
POSTGRES_PORT=5433
|
||||
@@ -14,6 +15,7 @@ APP_UID=1000
|
||||
APP_GID=1000
|
||||
LOG_LEVEL=info
|
||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
||||
RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
|
||||
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
|
||||
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
|
||||
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
|
||||
@@ -25,3 +27,6 @@ RTP_PORT_END=20000 # firewall and router. Two ports per call;
|
||||
#AI_TTS_URL=http://127.0.0.1:8000/v1
|
||||
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
#AI_TTS_VOICE=af_heart
|
||||
# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
|
||||
# in data/tls/; the api reads it at start, so restart the api after changing it.
|
||||
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
|
||||
@@ -27,6 +27,8 @@ write the software around it: database, config generator, API, web UI.
|
||||
| `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning |
|
||||
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
|
||||
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
|
||||
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
|
||||
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
||||
@@ -5,6 +5,70 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.8 — 2026-10-26
|
||||
|
||||
**Before you update:** this version changes the database (sign-in); the api backs up first, by
|
||||
itself. Portainer: change both tags to `0.0.8` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.8`. Sign-in works as before until you set up single sign-on in
|
||||
Settings → Sign-in (admin guide: [Sign-in](docs/admin-guide/sign-in.md)). Every route is now rate
|
||||
limited, 600 requests a minute per client IP by default (`RATE_LIMIT_PER_MINUTE`, 0 turns it off).
|
||||
|
||||
- **Sign in with OpenID Connect** (spec 12, admin guide: [Sign-in](docs/admin-guide/sign-in.md)):
|
||||
Keycloak (tested), and any provider that follows the standard: Authentik, Authelia, Zitadel,
|
||||
Entra ID and others are expected to work. People connect their
|
||||
account on My settings → Sign-in and then sign in with either it or their password. The admin
|
||||
picks passwords only, OpenID only, or both. Provider groups can decide who may sign in and who
|
||||
is an admin. Signing out also signs out at the provider, and someone disabled there loses their
|
||||
session here within 20 minutes. No accounts are made automatically: an admin still creates each
|
||||
user. Admins keep their password, so the phone system stays reachable if the provider is down.
|
||||
- **Sign in with the company directory** (LDAP / LDAPS): presets for Active Directory and Samba
|
||||
AD, OpenLDAP, FreeIPA and lldap (AD and lldap tested), and a Test button. People use their directory username or email
|
||||
and password. New users are matched by email; anyone who chose their own password, and every
|
||||
admin, connects once on My settings → Sign-in. An admin filter can make people admins (nested AD
|
||||
groups count).
|
||||
- **Leavers**: every hour, someone removed from the directory (or disabled there) is disabled
|
||||
here, password included. A dashboard warning shows when the directory can't be reached, or when
|
||||
a check would disable most people (it then disables nobody).
|
||||
- **Security** (a two-reviewer audit of the new sign-in and all 207 API routes): rate limits on
|
||||
every route, stricter on Test buttons; a password asked again (change password, two-step on and
|
||||
off) counts like a sign-in; a saved directory or provider password is never sent to a new
|
||||
address; and a role set by the provider is read-only on Users.
|
||||
- Database changes: `user_identities`; `sessions` records how each session was made; a user may
|
||||
have no password (the api backs up first).
|
||||
|
||||
## v0.0.7 — 2026-10-23
|
||||
|
||||
**Before you update:** this version changes the database (call reports, contacts); the api backs
|
||||
up first, by itself. Portainer: change both tags to `0.0.7` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.7`. Contacts start in Simple mode with empty address books;
|
||||
Contrack is optional and off until you set it up (admin guide: [Contacts](docs/admin-guide/contacts.md)).
|
||||
Desk phones pick up their new directory at their next daily check (or reboot them).
|
||||
|
||||
- **Contacts** (spec 11, admin guide: [Contacts](docs/admin-guide/contacts.md)): each person's own
|
||||
contacts and the company's, in My Phone → Contacts and Admin → Contacts. Import `.vcf` files
|
||||
(iPhone, Android, Google, Outlook, Apple) or CSV, with a preview; duplicates are skipped. Share
|
||||
contacts with colleagues or everyone: they Accept or Deny each one, and nothing lands in their
|
||||
contacts without it. Contact names show in call history, voicemail, Home, the browser phone,
|
||||
and the call report email, with **Add to contacts** on calls and voicemails from numbers that
|
||||
aren't contacts yet.
|
||||
- **Caller names on desk phones while they ring**: the caller's name from the ringing person's
|
||||
own contacts (or the company's), per phone, so nobody sees a colleague's private contact names;
|
||||
ring group and queue prefixes ("Sales:") stay in front.
|
||||
- **Desk phone directories** now have the phone owner's own contacts, after the extensions and
|
||||
before the company's.
|
||||
- **Contrack** (optional): the setup wizard (new step 8, Contacts) and Settings → Contacts offer
|
||||
[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager,
|
||||
as a second container (`deploy/contrack/compose.yml`, pinned to 2.0.2). Each person connects
|
||||
their own Contrack account in My settings → Contacts, and the company has one everyone sees.
|
||||
Calls go on the contact's Contrack timeline, optionally with the call summary and its action
|
||||
items (the first becomes a follow-up); contacts from before Contrack can be copied over.
|
||||
- **Call report email** (My settings → Call report): your calls every hour, day or week, at the
|
||||
time you pick (company time zone), with whether each was recorded and, optionally, each
|
||||
recorded call's summary. No calls, no email.
|
||||
- **Voicemail search**: search your messages by what was said (the transcript) or by the caller.
|
||||
- Database changes: call report columns on `extensions`; `contact_shares`, `contrack_call_log`,
|
||||
and Contrack columns on `users` (the api backs up first).
|
||||
|
||||
## v0.0.6 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (call transcripts); the api backs up
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.6), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.8), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.8",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -12,13 +12,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/rate-limit": "^11.2.1",
|
||||
"@fastify/static": "^10.1.6",
|
||||
"@fastify/websocket": "^11.3.4",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"ldapts": "^9.2.0",
|
||||
"nodemailer": "10.0.11",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"qrcode": "1.5.4",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+15
-1
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyRateLimit from '@fastify/rate-limit';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { sql } from 'drizzle-orm';
|
||||
@@ -11,9 +12,10 @@ import type { Db } from './db/client.ts';
|
||||
import type { Generator } from './generator/index.ts';
|
||||
import type { AmiClient } from './ami/client.ts';
|
||||
import { settingsRoutes } from './routes/settings.ts';
|
||||
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { HttpError, errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { authRoutes } from './routes/auth.ts';
|
||||
import { userRoutes } from './routes/users.ts';
|
||||
import { oidcRoutes } from './routes/oidc.ts';
|
||||
import { systemRoutes } from './routes/system.ts';
|
||||
import { extensionRoutes } from './routes/extensions.ts';
|
||||
import { trunkRoutes } from './routes/trunks.ts';
|
||||
@@ -39,6 +41,7 @@ import { liveCallRoutes } from './routes/livecalls.ts';
|
||||
import { integrationRoutes } from './routes/integrations.ts';
|
||||
import { reportRoutes } from './routes/reports.ts';
|
||||
import { aboutRoutes } from './routes/about.ts';
|
||||
import { contactRoutes } from './routes/contacts.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -90,6 +93,15 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.decorateRequest('sessionId', null);
|
||||
app.register(fastifyCookie);
|
||||
app.register(fastifyWebsocket);
|
||||
// Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
|
||||
// their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
|
||||
if (config.RATE_LIMIT_PER_MINUTE > 0)
|
||||
app.register(fastifyRateLimit, {
|
||||
max: config.RATE_LIMIT_PER_MINUTE,
|
||||
timeWindow: 60_000,
|
||||
errorResponseBuilder: (_req, ctx) =>
|
||||
new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
|
||||
});
|
||||
app.setErrorHandler(errorHandler);
|
||||
|
||||
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
|
||||
@@ -138,6 +150,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
|
||||
app.register(authRoutes);
|
||||
app.register(userRoutes);
|
||||
app.register(oidcRoutes);
|
||||
app.register(systemRoutes);
|
||||
app.register(extensionRoutes);
|
||||
app.register(trunkRoutes);
|
||||
@@ -146,6 +159,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(contactRoutes);
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
app.register(queueRoutes);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// preHandler guards (04 §2.4). Routes list them in `preHandler`.
|
||||
// preHandler guards (04 §2.4). Routes list them in `preHandler`; the rate limit presets go in
|
||||
// `config`.
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { users } from '../db/schema.ts';
|
||||
import { forbidden, unauthorized } from '../errors.ts';
|
||||
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
|
||||
import { getTokenUser } from './tokens.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
@@ -39,7 +40,7 @@ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Pro
|
||||
if (found.extended) setSessionCookie(reply, token!);
|
||||
req.user = found.user;
|
||||
req.sessionId = found.sessionId;
|
||||
if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
|
||||
if (mustChange(found) && !req.url.startsWith('/api/auth/'))
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
}
|
||||
|
||||
@@ -47,3 +48,8 @@ export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Pr
|
||||
await requireAuth(req, reply);
|
||||
if (req.user!.role !== 'admin') throw forbidden('Admins only');
|
||||
}
|
||||
|
||||
/** Route `config` for what calls out or does real work (Test buttons, downloads): 10 a minute. */
|
||||
export const SLOW = { rateLimit: { max: 10, timeWindow: 60_000 } };
|
||||
/** Route `config` for routes with their own limit (/prov) or only Asterisk on this machine. */
|
||||
export const NO_LIMIT = { rateLimit: false as const };
|
||||
@@ -15,21 +15,23 @@ function recent(key: string, windowMs: number, now: number): number[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
|
||||
/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */
|
||||
export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean {
|
||||
return (
|
||||
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
|
||||
(email !== null &&
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max)
|
||||
);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
|
||||
export function recordLoginFailure(ip: string, email: string | null, now = Date.now()): void {
|
||||
// Forget keys past every window, at most once a minute: emails tried once and never again
|
||||
// would otherwise stay until restart (08 §6).
|
||||
if (now - lastSweep > MIN) {
|
||||
lastSweep = now;
|
||||
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
|
||||
}
|
||||
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
|
||||
for (const key of [`ip:${ip}`, ...(email === null ? [] : [`email:${email.toLowerCase()}`])]) {
|
||||
failures.set(key, [...(failures.get(key) ?? []), now]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const COOKIE = 'sp_session';
|
||||
@@ -32,6 +33,9 @@ export async function createSession(
|
||||
userId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
method: 'password' | 'ldap' | 'oidc' = 'password',
|
||||
/** OpenID sessions (12 §3.5): kept encrypted for the leaver check and signing out there. */
|
||||
idp?: { refreshToken: string | null; idToken: string },
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.insert(sessions).values({
|
||||
@@ -40,6 +44,10 @@ export async function createSession(
|
||||
expiresAt: new Date(Date.now() + LIFETIME),
|
||||
ip,
|
||||
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||
method,
|
||||
idpRefreshEnc: idp?.refreshToken ? encrypt(idp.refreshToken) : null,
|
||||
idpIdTokenEnc: idp ? encrypt(idp.idToken) : null,
|
||||
idpCheckedAt: idp ? new Date() : null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
@@ -51,7 +59,7 @@ export async function createSession(
|
||||
export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
const id = sha256(token);
|
||||
const [row] = await db
|
||||
.select({ expiresAt: sessions.expiresAt, user: users })
|
||||
.select({ expiresAt: sessions.expiresAt, method: sessions.method, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
|
||||
@@ -64,9 +72,13 @@ export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
.where(eq(sessions.id, id));
|
||||
extended = true;
|
||||
}
|
||||
return { user: row.user, sessionId: id, extended };
|
||||
return { user: row.user, sessionId: id, method: row.method, extended };
|
||||
}
|
||||
|
||||
/** A password to change only blocks sessions made with it (OpenID sessions skip it, 12). */
|
||||
export const mustChange = (f: { user: { mustChangePassword: boolean }; method: string }) =>
|
||||
f.user.mustChangePassword && f.method === 'password';
|
||||
|
||||
export async function deleteSession(db: Db, token: string) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const EnvSchema = z
|
||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
TRUST_PROXY: z.stringbool().default(true),
|
||||
/** Requests per minute per client IP, every route (08 §2.4); stricter ones per route. 0: off. */
|
||||
RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(600),
|
||||
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
|
||||
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
|
||||
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Address books (11 §2): a user's (owner = their id) and the company's (owner null), kept in the
|
||||
// `contacts` table (builtin) or in Contrack (contacts.provider = 'contrack', 11 §4). Every caller
|
||||
// goes through these functions and doesn't care which.
|
||||
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
normalizeNumber,
|
||||
type ContactBookDto,
|
||||
type ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { contacts } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import {
|
||||
ContrackError,
|
||||
contrackBook,
|
||||
contrackCreate,
|
||||
contrackBulk,
|
||||
contrackSettings,
|
||||
needToken,
|
||||
type Contrack,
|
||||
} from './contrack.ts';
|
||||
|
||||
/** null = the company book. */
|
||||
export type Owner = string | null;
|
||||
const inBook = (owner: Owner) =>
|
||||
owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId);
|
||||
|
||||
type Row = typeof contacts.$inferSelect;
|
||||
export const toDto = (r: Row): ContactDto => ({
|
||||
id: r.id,
|
||||
book: r.ownerUserId ? 'mine' : 'company',
|
||||
name: r.name,
|
||||
company: r.company,
|
||||
email: r.email,
|
||||
numbers: r.numbers as ContactData['numbers'],
|
||||
notes: r.notes,
|
||||
url: null,
|
||||
});
|
||||
|
||||
/** Contrack's errors as API answers (409 for "connect first", 502 for Contrack itself). */
|
||||
const asHttp = (err: unknown): never => {
|
||||
if (err instanceof ContrackError)
|
||||
throw new HttpError(err.status === 409 ? 409 : 502, 'contrack', err.message);
|
||||
throw err;
|
||||
};
|
||||
|
||||
/** Editing, deleting and importing happen in Contrack itself in Contrack mode (11 §3.1, §4.3). */
|
||||
export async function assertBuiltin(db: Db) {
|
||||
if (await contrackSettings(db))
|
||||
throw new HttpError(409, 'contrack', 'Contacts live in Contrack: change them there');
|
||||
}
|
||||
|
||||
/** Normalized numbers (00 §8); `bad` gets the ones that aren't phone numbers. */
|
||||
export function cleanNumbers(c: ContactData, bad?: (n: string) => void): ContactData['numbers'] {
|
||||
const out: ContactData['numbers'] = [];
|
||||
for (const n of c.numbers)
|
||||
try {
|
||||
const number = normalizeNumber(n.number);
|
||||
if (!out.some((o) => o.number === number)) out.push({ label: n.label, number });
|
||||
} catch {
|
||||
if (!bad) throw badRequest(`"${n.number}" isn't a phone number`);
|
||||
bad(n.number);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const byName = (a: ContactDto, b: ContactDto) => a.name.localeCompare(b.name);
|
||||
|
||||
async function contrackList(db: Db, c: Contrack, owner: Owner) {
|
||||
const b = await contrackBook(db, c, owner);
|
||||
return { items: (b?.contacts ?? []).toSorted(byName), book: b };
|
||||
}
|
||||
|
||||
/**
|
||||
* The books someone sees: my contacts, then the company's (userId), or only the company's (null,
|
||||
* Admin → Contacts). `contrack` says whether that person's (or the company's) token works.
|
||||
*/
|
||||
export async function readBooks(db: Db, userId: string | null): Promise<ContactBookDto> {
|
||||
const c = await contrackSettings(db);
|
||||
if (!c) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
userId
|
||||
? or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId))
|
||||
: isNull(contacts.ownerUserId),
|
||||
)
|
||||
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
|
||||
return { items: rows.map(toDto), provider: 'builtin', contrack: null };
|
||||
}
|
||||
const company = await contrackList(db, c, null);
|
||||
const mine = userId ? await contrackList(db, c, userId) : null;
|
||||
const own = mine ?? company; // whose token the page is about
|
||||
return {
|
||||
items: [...(mine?.items ?? []), ...company.items],
|
||||
provider: 'contrack',
|
||||
contrack: {
|
||||
publicUrl: c.publicUrl,
|
||||
connected: !!own.book && !own.book.error,
|
||||
error: own.book?.error ?? null,
|
||||
toCopy:
|
||||
own.book && !own.book.error
|
||||
? (await notInContrack(db, userId, own.book.contacts)).length
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** This book's builtin contacts that its Contrack account doesn't have yet. */
|
||||
async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise<ContactData[]> {
|
||||
const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
||||
return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
|
||||
* in one Contrack import. The builtin ones stay, for switching back.
|
||||
*/
|
||||
export async function copyToContrack(db: Db, owner: Owner): Promise<ContactCopyResult> {
|
||||
const c = await contrackSettings(db);
|
||||
if (!c)
|
||||
throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
|
||||
const b = await contrackBook(db, c, owner);
|
||||
if (!b) asHttp(needToken(owner));
|
||||
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
||||
const all = (await db.select().from(contacts).where(inBook(owner))).length;
|
||||
const fresh = await notInContrack(db, owner, b!.contacts);
|
||||
if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
|
||||
const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
|
||||
return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
|
||||
}
|
||||
|
||||
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
|
||||
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise<ContactDto> {
|
||||
const clean = { ...d, numbers: cleanNumbers(d) };
|
||||
const c = await contrackSettings(db);
|
||||
if (c) return contrackCreate(db, c, owner, clean).catch(asHttp);
|
||||
const [row] = await db
|
||||
.insert(contacts)
|
||||
.values({ ...clean, ownerUserId: owner })
|
||||
.returning();
|
||||
return toDto(row!);
|
||||
}
|
||||
|
||||
/** Same contact: same name (any case) and a number in common, or neither has a number. */
|
||||
const sameKey = (c: ContactData) => c.name.trim().toLowerCase();
|
||||
const isDuplicate = (a: ContactData, b: ContactData) =>
|
||||
sameKey(a) === sameKey(b) &&
|
||||
((!a.numbers.length && !b.numbers.length) ||
|
||||
a.numbers.some((n) => b.numbers.some((m) => m.number === n.number)));
|
||||
|
||||
/**
|
||||
* Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares.
|
||||
* With `commit` false it only counts. Bad numbers are dropped and reported.
|
||||
*/
|
||||
export async function addToBook(
|
||||
db: Db,
|
||||
owner: Owner,
|
||||
list: ContactData[],
|
||||
commit: boolean,
|
||||
errors: string[] = [],
|
||||
): Promise<ContactImportResult> {
|
||||
const c = await contrackSettings(db);
|
||||
let have: ContactDto[];
|
||||
if (c) {
|
||||
const b = await contrackBook(db, c, owner);
|
||||
if (!b) asHttp(needToken(owner));
|
||||
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
||||
have = b!.contacts;
|
||||
} else have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
||||
const fresh: ContactData[] = [];
|
||||
let duplicates = 0;
|
||||
for (const d of list) {
|
||||
const numbers = cleanNumbers(d, (n) =>
|
||||
errors.push(`${d.name}: "${n}" isn't a phone number, left out`),
|
||||
);
|
||||
const clean = { ...d, numbers };
|
||||
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
|
||||
else fresh.push(clean);
|
||||
}
|
||||
if (commit && fresh.length) {
|
||||
// ponytail: one Contrack request per contact; imports go to Contrack's own importer, so
|
||||
// this is only accepted shares.
|
||||
if (c) for (const d of fresh) await contrackCreate(db, c, owner, d).catch(asHttp);
|
||||
// ponytail: one insert of up to 5,000 rows (the import cap).
|
||||
else await db.insert(contacts).values(fresh.map((d) => ({ ...d, ownerUserId: owner })));
|
||||
}
|
||||
return {
|
||||
committed: commit,
|
||||
added: fresh.length,
|
||||
duplicates,
|
||||
noNumber: fresh.filter((d) => !d.numbers.length).length,
|
||||
errors: errors.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Names for numbers, from my book first, then the company's (11 §1). Numbers as calls store them
|
||||
* (normalized). userId null: the company book only.
|
||||
*/
|
||||
export async function contactNames(
|
||||
db: Db,
|
||||
userId: string | null,
|
||||
numbers: (string | null)[],
|
||||
opts: { wait?: boolean } = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const want = [...new Set(numbers.filter((n): n is string => !!n))];
|
||||
const out = new Map<string, string>();
|
||||
if (!want.length) return out;
|
||||
const c = await contrackSettings(db);
|
||||
if (c) {
|
||||
const books = [
|
||||
userId ? await contrackBook(db, c, userId, opts) : null,
|
||||
await contrackBook(db, c, null, opts),
|
||||
];
|
||||
for (const b of books)
|
||||
for (const ct of b?.contacts ?? [])
|
||||
for (const n of ct.numbers)
|
||||
if (want.includes(n.number) && !out.has(n.number)) out.set(n.number, ct.name);
|
||||
return out;
|
||||
}
|
||||
// ponytail: a scan of the two books' numbers; a GIN index when books reach tens of thousands.
|
||||
const { rows } = await db.execute<{ name: string; number: string; mine: boolean }>(sql`
|
||||
select c.name, n->>'number' as number, c.owner_user_id is not null as mine
|
||||
from ${contacts} c, jsonb_array_elements(c.numbers) n
|
||||
where ${userId ? sql`(c.owner_user_id = ${userId} or c.owner_user_id is null)` : sql`c.owner_user_id is null`}
|
||||
and n->>'number' in ${want}`);
|
||||
for (const r of rows.sort((a, b) => Number(b.mine) - Number(a.mine)))
|
||||
if (!out.has(r.number)) out.set(r.number, r.name);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the
|
||||
// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for
|
||||
// the caller, and the phones show it.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */
|
||||
export const callerNameToken = (appSecret: string) =>
|
||||
createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex');
|
||||
|
||||
export const sameToken = (a: string, b: string) =>
|
||||
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
/**
|
||||
* What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at
|
||||
* most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could
|
||||
* read as syntax.
|
||||
*/
|
||||
export const phoneSafeName = (name: string) =>
|
||||
name
|
||||
.normalize('NFC')
|
||||
.replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST
|
||||
// routes with a person's (or the company account's) personal token, and keep each book in memory
|
||||
// for 5 minutes, so name lookups don't wait on it.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
|
||||
import { AiError, assertLocalUrl } from '../ai/client.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
export class ContrackError extends Error {
|
||||
status: number | undefined;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** One call to Contrack's API (base: the local address in Settings → Contacts). */
|
||||
async function ctk<T>(
|
||||
base: string,
|
||||
token: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
assertLocalUrl(base);
|
||||
} catch (err) {
|
||||
throw new ContrackError((err as AiError).message);
|
||||
}
|
||||
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as Error & { cause?: { code?: string } };
|
||||
throw new ContrackError(
|
||||
e.name === 'TimeoutError'
|
||||
? `Contrack (${url.host}) didn't answer within 10 s`
|
||||
: `Can't reach Contrack at ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`,
|
||||
);
|
||||
}
|
||||
if (r.status === 401) throw new ContrackError('Contrack refused the token', 401);
|
||||
if (!r.ok) {
|
||||
const body = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
|
||||
throw new ContrackError(`Contrack: ${body?.error?.message ?? `HTTP ${r.status}`}`, r.status);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
type Slim = {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string | null;
|
||||
emails: { email: string }[];
|
||||
phones: { phone: string }[];
|
||||
};
|
||||
|
||||
export type Contrack = { url: string; publicUrl: string | null; companyToken: string | null };
|
||||
|
||||
/** Settings → Contacts, or null in builtin mode. */
|
||||
export async function contrackSettings(db: Db): Promise<Contrack | null> {
|
||||
if ((await getSetting(db, 'contacts.provider')) !== 'contrack') return null;
|
||||
const c = await getSetting(db, 'contacts.contrack');
|
||||
if (!c) return null;
|
||||
return {
|
||||
url: c.url,
|
||||
publicUrl: c.publicUrl,
|
||||
companyToken: c.companyTokenEnc ? decrypt(c.companyTokenEnc) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The token for a book: a user's own, or the company account's (owner null). */
|
||||
export async function tokenFor(db: Db, c: Contrack, owner: string | null): Promise<string | null> {
|
||||
if (owner === null) return c.companyToken;
|
||||
const [u] = await db
|
||||
.select({ enc: users.contrackTokenEnc })
|
||||
.from(users)
|
||||
.where(eq(users.id, owner));
|
||||
return u?.enc ? decrypt(u.enc) : null;
|
||||
}
|
||||
|
||||
const toDto = (c: Contrack, s: Slim, book: ContactDto['book']): ContactDto => {
|
||||
const numbers: ContactData['numbers'] = [];
|
||||
for (const p of s.phones)
|
||||
try {
|
||||
const number = normalizeNumber(p.phone);
|
||||
if (!numbers.some((n) => n.number === number)) numbers.push({ label: '', number });
|
||||
} catch {
|
||||
// Not a number a phone can dial ("ask reception"): left out.
|
||||
}
|
||||
return {
|
||||
id: s.id,
|
||||
book,
|
||||
name: s.name,
|
||||
company: s.company,
|
||||
email: s.emails[0]?.email ?? null,
|
||||
numbers,
|
||||
notes: null,
|
||||
url: c.publicUrl ? `${c.publicUrl}/contact/${encodeURIComponent(s.id)}` : null,
|
||||
};
|
||||
};
|
||||
|
||||
type Book = { contacts: ContactDto[]; at: number; error: string | null };
|
||||
/** By owner: a user id, or '' for the company account. Keyed by token too, so a new one refetches. */
|
||||
const cache = new Map<string, Book & { token: string }>();
|
||||
const TTL = 5 * 60_000;
|
||||
|
||||
/** Forget a book (after we add to it, or a token changes). */
|
||||
export const forgetBook = (owner: string | null) => cache.delete(owner ?? '');
|
||||
|
||||
const inflight = new Map<string, Promise<Book>>();
|
||||
|
||||
/**
|
||||
* A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing
|
||||
* Contrack keeps the last good list and says why. `wait: false` (a phone ringing, 11 §6 C4)
|
||||
* never waits on Contrack: it answers what the cache has and refreshes in the background.
|
||||
*/
|
||||
export async function contrackBook(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
{ now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {},
|
||||
): Promise<Book | null> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) return null;
|
||||
const key = owner ?? '';
|
||||
const hit = cache.get(key);
|
||||
const usable = hit && hit.token === token ? hit : undefined;
|
||||
if (usable && now - usable.at < TTL) return usable;
|
||||
if (!inflight.has(key))
|
||||
inflight.set(
|
||||
key,
|
||||
fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)),
|
||||
);
|
||||
if (!wait) return usable ?? null;
|
||||
return inflight.get(key)!;
|
||||
}
|
||||
|
||||
async function fetchBook(
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
token: string,
|
||||
hit: Book | undefined,
|
||||
now: number,
|
||||
): Promise<Book> {
|
||||
const key = owner ?? '';
|
||||
const book = owner === null ? 'company' : 'mine';
|
||||
try {
|
||||
const rows = await ctk<Slim[]>(c.url, token, 'api/contacts?view=slim');
|
||||
const fresh = { contacts: rows.map((r) => toDto(c, r, book)), at: now, error: null, token };
|
||||
cache.set(key, fresh);
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
const stale = {
|
||||
contacts: hit?.contacts ?? [],
|
||||
at: now, // tried now: the next try is in 5 minutes, not on every lookup
|
||||
error: (err as Error).message,
|
||||
token,
|
||||
};
|
||||
cache.set(key, stale);
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writing to a book whose owner hasn't connected Contrack. */
|
||||
export const needToken = (owner: string | null) =>
|
||||
new ContrackError(
|
||||
owner
|
||||
? 'Connect your Contrack account first (My settings → Contacts)'
|
||||
: 'The company Contrack account has no token (Settings → Contacts)',
|
||||
409,
|
||||
);
|
||||
|
||||
/** Adds a contact to a Contrack book (11 §4.3: "Add to contacts", an accepted share). */
|
||||
export async function contrackCreate(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
d: ContactData,
|
||||
): Promise<ContactDto> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) throw needToken(owner);
|
||||
const row = await ctk<Slim>(c.url, token, 'api/contacts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: d.name,
|
||||
...(d.company ? { company: d.company } : {}),
|
||||
...(d.email ? { emails: [{ email: d.email }] } : {}),
|
||||
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
|
||||
}),
|
||||
});
|
||||
forgetBook(owner);
|
||||
return toDto(c, row, owner === null ? 'company' : 'mine');
|
||||
}
|
||||
|
||||
/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
|
||||
export async function contrackBulk(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
list: ContactData[],
|
||||
): Promise<{ count: number; failed: number }> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) throw needToken(owner);
|
||||
const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Import-Id': randomUUID() },
|
||||
body: JSON.stringify(
|
||||
list.slice(0, 5000).map((d) => ({
|
||||
name: d.name,
|
||||
...(d.company ? { company: d.company } : {}),
|
||||
...(d.email ? { emails: [{ email: d.email }] } : {}),
|
||||
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
|
||||
})),
|
||||
),
|
||||
});
|
||||
forgetBook(owner);
|
||||
return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
|
||||
}
|
||||
|
||||
/** A timeline entry on a contact (11 §4.4); answers its id. */
|
||||
export async function contrackInteraction(
|
||||
c: Contrack,
|
||||
token: string,
|
||||
contactId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const r = await ctk<{ id: string }>(
|
||||
c.url,
|
||||
token,
|
||||
`api/contacts/${encodeURIComponent(contactId)}/interactions`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
);
|
||||
return r.id;
|
||||
}
|
||||
|
||||
/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */
|
||||
export async function contrackCheck(url: string, token: string | null): Promise<string> {
|
||||
try {
|
||||
assertLocalUrl(url);
|
||||
} catch (err) {
|
||||
throw new ContrackError((err as AiError).message);
|
||||
}
|
||||
const health = await fetch(new URL('healthz', url.endsWith('/') ? url : `${url}/`), {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => null);
|
||||
if (!health?.ok) throw new ContrackError(`Can't reach Contrack at ${url}`);
|
||||
if (!token) return 'Contrack answers.';
|
||||
const rows = await ctk<Slim[]>(url, token, 'api/contacts?view=slim');
|
||||
return `Contrack answers; the token reads ${rows.length} contact${rows.length === 1 ? '' : 's'}.`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// CSV to contacts (04 §7.4): name,company,email,label1,number1,label2,number2,… with a header row
|
||||
// (any order, any case; notes too).
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
/** RFC 4180 rows (blank ones too): quoted cells may hold commas, quotes ("") and line breaks. */
|
||||
export function csvRows(input: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = '';
|
||||
let quoted = false;
|
||||
const s = input.replace(/^\uFEFF/, '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i]!;
|
||||
if (quoted) {
|
||||
if (c === '"' && s[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else cell += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ',') {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && s[i + 1] === '\n') i++;
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
} else cell += c;
|
||||
}
|
||||
if (cell || row.length) rows.push([...row, cell]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseCsv(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
const [header, ...rows] = csvRows(input);
|
||||
if (!header) return { contacts: [], errors: [] };
|
||||
const col = new Map(header.map((h, i) => [h.trim().toLowerCase(), i]));
|
||||
if (!col.has('name'))
|
||||
return { contacts: [], errors: ['The first row must name the columns, with a "name" column'] };
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
if (!r.some((c) => c.trim())) return;
|
||||
const get = (k: string) => {
|
||||
const at = col.get(k);
|
||||
return at === undefined ? '' : (r[at] ?? '').trim();
|
||||
};
|
||||
const numbers: ContactData['numbers'] = [];
|
||||
for (let n = 1; col.has(`number${n}`); n++)
|
||||
if (get(`number${n}`)) numbers.push({ label: get(`label${n}`), number: get(`number${n}`) });
|
||||
const name = get('name');
|
||||
if (!name) {
|
||||
errors.push(`Row ${i + 2}: no name, skipped`);
|
||||
return;
|
||||
}
|
||||
contacts.push({
|
||||
name,
|
||||
company: get('company') || null,
|
||||
email: get('email') || null,
|
||||
numbers,
|
||||
notes: get('notes') || null,
|
||||
});
|
||||
});
|
||||
return { contacts, errors };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C5: what a call looks like on a Contrack timeline (11 §4.4).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts';
|
||||
|
||||
const call = (c: Partial<TimelineCall>): TimelineCall => ({
|
||||
out: false,
|
||||
status: 'answered',
|
||||
fromNumber: '13125551234',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 192,
|
||||
answeredBy: null,
|
||||
recorded: false,
|
||||
...c,
|
||||
});
|
||||
|
||||
describe('timelineEntry', () => {
|
||||
it('titles calls from where the person sat', () => {
|
||||
const t = (c: Partial<TimelineCall>) => timelineEntry(call(c), null).title;
|
||||
expect(t({})).toBe('Incoming call');
|
||||
expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith');
|
||||
expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call');
|
||||
expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left');
|
||||
expect(t({ out: true })).toBe('Outgoing call');
|
||||
expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy');
|
||||
expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer');
|
||||
});
|
||||
|
||||
it('writes the facts, the summary and its action items as escaped HTML', () => {
|
||||
const e = timelineEntry(
|
||||
call({ recorded: true }),
|
||||
'Acme <wants> a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit',
|
||||
);
|
||||
expect(e).toEqual({
|
||||
title: 'Incoming call',
|
||||
content:
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded</p>' +
|
||||
'<p>Acme <wants> a quote & a call back.</p>' +
|
||||
'<p>Action items:</p><ul><li>Dan sends the quote</li><li>Sue books a visit</li></ul>',
|
||||
duration: 4,
|
||||
firstItem: 'Dan sends the quote',
|
||||
});
|
||||
const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null);
|
||||
expect([missed.content, missed.duration, missed.firstItem]).toEqual([
|
||||
'<p>From (312) 555-1234 to (312) 555-0000</p>',
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(timelineEntry(call({ out: true }), null).content).toBe(
|
||||
'<p>To (312) 555-0000 · 3:12</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads "Action items: none" as none', () => {
|
||||
expect(splitSummary('Wrong number.\nAction items: none')).toEqual({
|
||||
text: ['Wrong number.'],
|
||||
items: [],
|
||||
});
|
||||
expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] });
|
||||
expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their
|
||||
// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's
|
||||
// summary if they asked for it. Unknown numbers are not added to Contrack.
|
||||
import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contrackCallLog,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
|
||||
|
||||
/** A recorded call waits this long for its summary before it's logged without one. */
|
||||
const SUMMARY_WAIT_MS = 30 * 60_000;
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number');
|
||||
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
|
||||
export type TimelineCall = {
|
||||
out: boolean;
|
||||
status: string;
|
||||
fromNumber: string | null;
|
||||
toNumber: string | null;
|
||||
talkSeconds: number;
|
||||
/** Someone else answered (a ring group or queue colleague). */
|
||||
answeredBy: string | null;
|
||||
recorded: boolean;
|
||||
};
|
||||
|
||||
/** "Action items:" lines of a summary (10 §3), and the text before them. */
|
||||
export function splitSummary(summary: string): { text: string[]; items: string[] } {
|
||||
const lines = summary
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const at = lines.findIndex((l) => /^action items:/i.test(l));
|
||||
if (at < 0) return { text: lines, items: [] };
|
||||
return {
|
||||
text: lines.slice(0, at),
|
||||
items: lines
|
||||
.slice(at + 1)
|
||||
.filter((l) => /^[-*•]\s+/.test(l))
|
||||
.map((l) => l.replace(/^[-*•]\s+/, ''))
|
||||
.filter((l) => !/^none\.?$/i.test(l)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The interaction for one call: title, HTML content, minutes, the first action item. */
|
||||
export function timelineEntry(c: TimelineCall, summary: string | null) {
|
||||
const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status);
|
||||
const title = c.out
|
||||
? c.status === 'answered'
|
||||
? 'Outgoing call'
|
||||
: `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}`
|
||||
: c.status === 'voicemail'
|
||||
? 'Missed call, voicemail left'
|
||||
: missed || c.status !== 'answered'
|
||||
? 'Missed call'
|
||||
: c.answeredBy
|
||||
? `Incoming call, answered by ${c.answeredBy}`
|
||||
: 'Incoming call';
|
||||
const facts = [
|
||||
c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`,
|
||||
...(c.talkSeconds ? [mmss(c.talkSeconds)] : []),
|
||||
...(c.recorded ? ['recorded'] : []),
|
||||
];
|
||||
const parts = [`<p>${esc(facts.join(' · '))}</p>`];
|
||||
const s = summary ? splitSummary(summary) : null;
|
||||
if (s?.text.length) parts.push(...s.text.map((l) => `<p>${esc(l)}</p>`));
|
||||
if (s?.items.length)
|
||||
parts.push(`<p>Action items:</p><ul>${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>`);
|
||||
return {
|
||||
title,
|
||||
content: parts.join(''),
|
||||
duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null,
|
||||
firstItem: s?.items[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
|
||||
/**
|
||||
* One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of
|
||||
* people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on
|
||||
* the next run.
|
||||
*/
|
||||
export async function logCallsToContrack(d: {
|
||||
db: Db;
|
||||
log: { error(o: object, m: string): void };
|
||||
now?: Date;
|
||||
}): Promise<number> {
|
||||
const c = await contrackSettings(d.db);
|
||||
if (!c) return 0;
|
||||
const now = d.now ?? new Date();
|
||||
const tz = await getSetting(d.db, 'general.timezone');
|
||||
const summariesOn =
|
||||
(await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries'));
|
||||
const rows = await d.db
|
||||
.select({
|
||||
call: calls,
|
||||
role: callExtensions.role,
|
||||
userId: users.id,
|
||||
withSummary: users.contrackLogSummaries,
|
||||
tokenEnc: users.contrackTokenEnc,
|
||||
extId: extensions.id,
|
||||
answeredById: answerer.id,
|
||||
answeredBy: answerer.name,
|
||||
summary: recordings.summary,
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
})
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
|
||||
.where(
|
||||
and(
|
||||
isNotNull(users.contrackTokenEnc),
|
||||
eq(users.contrackLogCalls, true),
|
||||
eq(users.isActive, true),
|
||||
ne(calls.direction, 'internal'),
|
||||
gt(calls.endedAt, users.contrackLogSince),
|
||||
lt(calls.endedAt, new Date(now.getTime() - 60_000)),
|
||||
gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)),
|
||||
sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(calls.endedAt))
|
||||
.limit(500);
|
||||
|
||||
// One entry per call and person (a person can be in a call as caller, rang and answered).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
|
||||
for (const r of rows) {
|
||||
const k = `${r.call.id}|${r.userId}`;
|
||||
const out = r.role === 'caller' || todo.get(k)?.out === true;
|
||||
todo.set(k, { ...r, out });
|
||||
}
|
||||
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
|
||||
let logged = 0;
|
||||
for (const r of todo.values()) {
|
||||
if (skipUser.has(r.userId)) continue;
|
||||
const record = (interactionId: string | null, error: string | null = null) =>
|
||||
d.db
|
||||
.insert(contrackCallLog)
|
||||
.values({ callId: r.call.id, userId: r.userId, interactionId, error })
|
||||
.onConflictDoNothing();
|
||||
const book = await contrackBook(d.db, c, r.userId);
|
||||
if (!book || book.error) {
|
||||
skipUser.add(r.userId);
|
||||
continue;
|
||||
}
|
||||
const other = r.out ? r.call.toNumber : r.call.fromNumber;
|
||||
const contact = other
|
||||
? book.contacts.find((x) => x.numbers.some((n) => n.number === other))
|
||||
: undefined;
|
||||
if (!contact) {
|
||||
await record(null);
|
||||
continue;
|
||||
}
|
||||
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
|
||||
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
|
||||
continue; // the transcript job hasn't summarized it yet
|
||||
const e = timelineEntry(
|
||||
{
|
||||
out: r.out,
|
||||
status: r.call.status,
|
||||
fromNumber: r.call.fromNumber,
|
||||
toNumber: r.call.toNumber,
|
||||
talkSeconds: r.call.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
||||
recorded: r.call.recordingId !== null,
|
||||
},
|
||||
wantSummary ? r.summary : null,
|
||||
);
|
||||
try {
|
||||
const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, {
|
||||
type: 'call',
|
||||
title: e.title,
|
||||
content: e.content,
|
||||
date: r.call.startedAt.toISOString(),
|
||||
duration: e.duration,
|
||||
source: 'selfphoneng',
|
||||
...(e.firstItem
|
||||
? {
|
||||
actionItem: {
|
||||
title: e.firstItem.slice(0, 200),
|
||||
dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await record(id);
|
||||
logged++;
|
||||
} catch (err) {
|
||||
const status = err instanceof ContrackError ? err.status : undefined;
|
||||
// Refused (bad request, a read-only token, a deleted contact): noted, not retried.
|
||||
if (status && status >= 400 && status < 500 && status !== 401 && status !== 429)
|
||||
await record(null, (err as Error).message);
|
||||
else {
|
||||
skipUser.add(r.userId);
|
||||
d.log.error({ err, user: r.userId }, 'contrack: call not logged yet');
|
||||
}
|
||||
}
|
||||
}
|
||||
return logged;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C1: vCard and CSV imports read what phones and address books export (11 §3.1).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsv } from './csv.ts';
|
||||
import { parseVcards } from './vcard.ts';
|
||||
|
||||
const IPHONE = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
|
||||
'N:Doe;Jane;;;',
|
||||
'FN:Jane Doe',
|
||||
'ORG:Acme Corp;',
|
||||
'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com',
|
||||
'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234',
|
||||
'item1.TEL:+1 312 555 0000',
|
||||
'item1.X-ABLabel:_$!<Main>!$_',
|
||||
'NOTE:Met at the fair\\, 2026\\nCall after 5',
|
||||
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
|
||||
' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const GOOGLE = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Bob Smith
|
||||
N:Smith;Bob;;;
|
||||
TEL;TYPE=WORK:+13125559999
|
||||
TEL;TYPE=HOME:3125558888
|
||||
EMAIL;TYPE=INTERNET:bob@example.com
|
||||
ORG:Widgets Inc.
|
||||
item1.URL:http\\://example.com
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:;;;;
|
||||
TEL;TYPE=CELL:3125550101
|
||||
END:VCARD
|
||||
`;
|
||||
|
||||
const V21 = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;
|
||||
TEL;CELL:+49301234567
|
||||
TEL;WORK;FAX:+49301234500
|
||||
NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw=
|
||||
o
|
||||
END:VCARD`;
|
||||
|
||||
const V40 = `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Carol Lee
|
||||
TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
NOTE:nothing to call
|
||||
END:VCARD`;
|
||||
|
||||
describe('parseVcards', () => {
|
||||
it('reads an iPhone export: names, company, labels, note; skips the photo', () => {
|
||||
expect(parseVcards(IPHONE)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme Corp',
|
||||
email: 'jane@example.com',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '(312) 555-1234' },
|
||||
{ label: 'main', number: '+1 312 555 0000' },
|
||||
],
|
||||
notes: 'Met at the fair, 2026\nCall after 5',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a Google export, and names a card with only a number after it', () => {
|
||||
const { contacts } = parseVcards(GOOGLE);
|
||||
expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([
|
||||
[
|
||||
'Bob Smith',
|
||||
'Widgets Inc.',
|
||||
[
|
||||
{ label: 'work', number: '+13125559999' },
|
||||
{ label: 'home', number: '3125558888' },
|
||||
],
|
||||
],
|
||||
['3125550101', null, [{ label: 'mobile', number: '3125550101' }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => {
|
||||
const [c] = parseVcards(V21).contacts;
|
||||
expect(c).toMatchObject({
|
||||
name: 'Dr. Jürgen Müller',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '+49301234567' },
|
||||
{ label: 'fax', number: '+49301234500' },
|
||||
],
|
||||
notes: 'Line one\r\nLine two',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => {
|
||||
expect(parseVcards(V40)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Carol Lee',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '+1-312-555-7777' }],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Card 2: no name or number, skipped'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => {
|
||||
const csv =
|
||||
'Number1,Name,label1,company,notes,number2,label2\r\n' +
|
||||
'312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' +
|
||||
',,,,,,\r\n' +
|
||||
'3125550000,,work,,,,\r\n' +
|
||||
'+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n';
|
||||
expect(parseCsv(csv)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Doe, Jane',
|
||||
company: 'Acme',
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '312-555-1234' }],
|
||||
notes: 'two\nlines',
|
||||
},
|
||||
{
|
||||
name: 'Bob "B" Smith',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [
|
||||
{ label: '', number: '+44 20 7946 0958' },
|
||||
{ label: 'desk', number: '101' },
|
||||
],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Row 4: no name, skipped'],
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a header with a name column', () => {
|
||||
expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
|
||||
// the note. Everything else (photos, addresses, birthdays) is ignored.
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
type Prop = { group: string; name: string; params: Map<string, string[]>; value: string };
|
||||
|
||||
/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
|
||||
function parseParams(parts: string[]): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf('=');
|
||||
const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
|
||||
const raw = eq < 0 ? p : p.slice(eq + 1);
|
||||
const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
|
||||
// 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
|
||||
const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
|
||||
out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
|
||||
function parseLine(line: string): Prop | null {
|
||||
let quoted = false;
|
||||
let colon = -1;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === '"') quoted = !quoted;
|
||||
else if (line[i] === ':' && !quoted) {
|
||||
colon = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (colon < 0) return null;
|
||||
const [head = '', ...params] = line.slice(0, colon).split(';');
|
||||
const dot = head.lastIndexOf('.');
|
||||
return {
|
||||
group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
|
||||
name: head.slice(dot + 1).toUpperCase(),
|
||||
params: parseParams(params),
|
||||
value: line.slice(colon + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQp(v: string, charset: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const hex = v.slice(i + 1, i + 3);
|
||||
if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 2;
|
||||
} else bytes.push(v.charCodeAt(i) & 0xff);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder(charset).decode(new Uint8Array(bytes));
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
|
||||
function components(v: string, sep: ';' | ','): string[] {
|
||||
const out = [''];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const c = v[i]!;
|
||||
if (c === '\\' && i + 1 < v.length) {
|
||||
const n = v[++i]!;
|
||||
out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
|
||||
} else if (c === sep) out.push('');
|
||||
else out[out.length - 1] += c;
|
||||
}
|
||||
return out.map((s) => s.trim());
|
||||
}
|
||||
const text = (v: string) => components(v, ';').join(';').trim();
|
||||
|
||||
/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
|
||||
function telLabel(types: string[], abLabel: string | undefined): string {
|
||||
if (abLabel) {
|
||||
const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
|
||||
if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
|
||||
}
|
||||
if (types.includes('FAX')) return 'fax';
|
||||
if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
|
||||
if (types.includes('WORK')) return 'work';
|
||||
if (types.includes('HOME')) return 'home';
|
||||
if (types.includes('MAIN')) return 'main';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Every card in the text, in order. A card with no name, company or number is an error. */
|
||||
export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
// Unfold: a line starting with a space or tab continues the one before (and 2.1
|
||||
// quoted-printable continues a line that ends with "=").
|
||||
const lines: string[] = [];
|
||||
for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
|
||||
const prev = lines.at(-1);
|
||||
if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
|
||||
lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
|
||||
else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
|
||||
else lines.push(l);
|
||||
}
|
||||
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
let card: Prop[] | null = null;
|
||||
for (const line of lines) {
|
||||
const p = parseLine(line);
|
||||
if (!p) continue;
|
||||
if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
|
||||
else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
|
||||
const c = toContact(card);
|
||||
if (c) contacts.push(c);
|
||||
else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
|
||||
card = null;
|
||||
} else if (card) {
|
||||
const enc = p.params.get('ENCODING') ?? [];
|
||||
if (enc.includes('QUOTED-PRINTABLE'))
|
||||
p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
|
||||
card.push(p);
|
||||
}
|
||||
}
|
||||
return { contacts, errors };
|
||||
}
|
||||
|
||||
function toContact(props: Prop[]): ContactData | null {
|
||||
const first = (n: string) => props.find((p) => p.name === n)?.value;
|
||||
const fn = first('FN') ? text(first('FN')!) : '';
|
||||
const n = first('N');
|
||||
const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
|
||||
? components(n, ';')
|
||||
: [];
|
||||
const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
|
||||
const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
|
||||
const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
|
||||
const abLabels = new Map(
|
||||
props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
|
||||
);
|
||||
const numbers = props
|
||||
.filter((p) => p.name === 'TEL')
|
||||
.map((p) => ({
|
||||
label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
|
||||
number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
|
||||
}))
|
||||
.filter((t) => t.number);
|
||||
const note = first('NOTE') ? text(first('NOTE')!) || null : null;
|
||||
const name = fn || fromN || org || numbers[0]?.number;
|
||||
if (!name) return null;
|
||||
return { name, company: org, email, numbers, notes: note };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TYPE "public"."call_report" AS ENUM('off', 'hourly', 'daily', 'weekly');--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report" "call_report" DEFAULT 'off' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_time" text DEFAULT '08:00' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_weekday" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_sent_at" timestamp with time zone;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contact_shares" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"from_user_id" uuid,
|
||||
"to_user_id" uuid NOT NULL,
|
||||
"source_id" text NOT NULL,
|
||||
"contact" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"decided_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_token_enc" text;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contrack_call_log" (
|
||||
"call_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"interaction_id" text,
|
||||
"error" text,
|
||||
"logged_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- People already connected (C3) start logging now, not with the last 2 days of calls.
|
||||
UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"kind" "identity_kind" NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"email" text,
|
||||
"groups" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
|
||||
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,41 @@
|
||||
"when": 1792626762332,
|
||||
"tag": "0007_recording_transcripts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1792781321577,
|
||||
"tag": "0008_call_reports",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1792784242837,
|
||||
"tag": "0009_contact_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1792785318384,
|
||||
"tag": "0010_contrack_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1792788393381,
|
||||
"tag": "0011_contrack_call_log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1792951905226,
|
||||
"tag": "0012_sso",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
|
||||
id: id(),
|
||||
email: citext().notNull().unique(),
|
||||
displayName: text().notNull(),
|
||||
passwordHash: text().notNull(),
|
||||
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
|
||||
passwordHash: text(),
|
||||
role: userRole().notNull().default('user'),
|
||||
isActive: boolean().notNull().default(true),
|
||||
totpSecretEnc: text(),
|
||||
@@ -63,6 +65,13 @@ export const users = pgTable('users', {
|
||||
totpLastStep: bigint({ mode: 'number' }),
|
||||
mustChangePassword: boolean().notNull().default(false),
|
||||
lastLoginAt: tstz(),
|
||||
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
|
||||
contrackTokenEnc: text(),
|
||||
/** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
|
||||
contrackLogCalls: boolean().notNull().default(true),
|
||||
/** …with each recorded call's summary (10 §3). */
|
||||
contrackLogSummaries: boolean().notNull().default(false),
|
||||
contrackLogSince: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -76,9 +85,40 @@ export const sessions = pgTable(
|
||||
expiresAt: tstz().notNull(),
|
||||
ip: inet(),
|
||||
userAgent: text(),
|
||||
/** How it was made (12 §2): password, ldap or oidc. */
|
||||
method: text().notNull().default('password'),
|
||||
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
|
||||
idpRefreshEnc: text(),
|
||||
idpIdTokenEnc: text(),
|
||||
idpCheckedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.userId)],
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
|
||||
],
|
||||
);
|
||||
|
||||
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
|
||||
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
|
||||
export const userIdentities = pgTable(
|
||||
'user_identities',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
kind: identityKind().notNull(),
|
||||
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
|
||||
subject: text().notNull(),
|
||||
/** As the provider last said. */
|
||||
email: text(),
|
||||
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
|
||||
groups: emptyTextArray(),
|
||||
createdAt: createdAt(),
|
||||
lastUsedAt: tstz(),
|
||||
},
|
||||
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
|
||||
);
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
@@ -102,6 +142,7 @@ export const outboundClass = pgEnum('outbound_class', [
|
||||
'international',
|
||||
]);
|
||||
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']);
|
||||
export const callReport = pgEnum('call_report', ['off', 'hourly', 'daily', 'weekly']);
|
||||
|
||||
export const extensions = pgTable('extensions', {
|
||||
id: id(),
|
||||
@@ -128,6 +169,13 @@ export const extensions = pgTable('extensions', {
|
||||
language: text().notNull().default('en'),
|
||||
mohClass: text().notNull().default('default'),
|
||||
blfNumbers: emptyTextArray(),
|
||||
/** The call report email (05 §4): how often, at what local time (HH:MM) and weekday (0 = Sunday). */
|
||||
callReport: callReport().notNull().default('off'),
|
||||
callReportTime: text().notNull().default('08:00'),
|
||||
callReportWeekday: integer().notNull().default(1),
|
||||
callReportSummaries: boolean().notNull().default(false),
|
||||
/** The end of the last period reported on (or when the schedule last changed). */
|
||||
callReportSentAt: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -612,6 +660,49 @@ export const contacts = pgTable('contacts', {
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
|
||||
export const contrackCallLog = pgTable(
|
||||
'contrack_call_log',
|
||||
{
|
||||
callId: uuid()
|
||||
.notNull()
|
||||
.references(() => calls.id, { onDelete: 'cascade' }),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
|
||||
interactionId: text(),
|
||||
error: text(),
|
||||
loggedAt: tstz().notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.callId, t.userId] })],
|
||||
);
|
||||
|
||||
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
|
||||
export const contactShares = pgTable(
|
||||
'contact_shares',
|
||||
{
|
||||
id: id(),
|
||||
fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
toUserId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The shared contact's id in the sender's book: offering it again replaces a pending offer. */
|
||||
sourceId: text().notNull(),
|
||||
/** A copy taken when shared: { name, company, email, numbers, notes }. */
|
||||
contact: jsonb().notNull(),
|
||||
status: text().notNull().default('pending'),
|
||||
decidedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index()
|
||||
.on(t.toUserId)
|
||||
.where(sql`${t.status} = 'pending'`),
|
||||
check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const smsMessages = pgTable(
|
||||
'sms_messages',
|
||||
{
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_103=2
|
||||
SP_CLASS_104=2
|
||||
SP_CLASS_105=2
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=2
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -260,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
|
||||
exten => s,1,Set(__SP_TARGET=550)
|
||||
same => n,Answer()
|
||||
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX})
|
||||
same => n,Gosub(sp-sub-record,s,1)
|
||||
same => n,Queue(550,t,,,300)
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -260,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222500]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX})
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,101,1)
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_1=2
|
||||
SP_CLASS_12=2
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=http://127.0.0.1:8080/api/internal/caller-name
|
||||
SP_CNAM_TOKEN=0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
|
||||
SP_CLASS_101=2
|
||||
+24
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -284,6 +306,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
exten => 13125559876,1,Set(__SP_DID=13125559876)
|
||||
same => n,Set(CDR(SP_DID)=13125559876)
|
||||
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=SALES: ${SP_CNAM_PREFIX})
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
@@ -117,5 +117,9 @@
|
||||
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
|
||||
}
|
||||
],
|
||||
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448"
|
||||
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448",
|
||||
"callerNames": {
|
||||
"url": "http://127.0.0.1:8080/api/internal/caller-name",
|
||||
"token": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"
|
||||
}
|
||||
}
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=0
|
||||
SP_CLASS_102=1
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -95,7 +95,13 @@ export function renderDids(snap: Snapshot): string {
|
||||
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
||||
`exten => ${num},1,Set(__SP_DID=${num})`,
|
||||
` same => n,Set(CDR(SP_DID)=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
// Recorded number: the caller hears the notice (if set) before the recording starts.
|
||||
...(d.recordInbound && snap.recordingAnnouncement
|
||||
? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`]
|
||||
|
||||
@@ -75,6 +75,9 @@ export function renderGlobals(snap: Snapshot): string {
|
||||
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
|
||||
// Spec 10 §3: also record each side on its own, for the transcript job.
|
||||
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
|
||||
// 11 §6 C4: where sp-sub-cnam asks for a caller's name in the ringing user's contacts.
|
||||
`SP_CNAM_URL=${snap.callerNames ? s.localUrl(snap.callerNames.url) : ''}`,
|
||||
`SP_CNAM_TOKEN=${snap.callerNames ? s.secret(snap.callerNames.token) : ''}`,
|
||||
];
|
||||
const exts = [...snap.extensions].sort(byNumber);
|
||||
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
|
||||
|
||||
@@ -18,7 +18,13 @@ export function renderQueueDialplan(snap: Snapshot): string {
|
||||
`[sp-queue-${s.ident(q.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
' same => n,Answer()',
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []),
|
||||
` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`,
|
||||
` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`,
|
||||
|
||||
@@ -22,7 +22,13 @@ export function renderRingGroups(snap: Snapshot): string {
|
||||
`; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`,
|
||||
`[sp-rg-${s.ident(g.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
...(g.members.length === 0
|
||||
? []
|
||||
: g.strategy === 'ringall'
|
||||
|
||||
@@ -12,16 +12,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web))
|
||||
same => n,GotoIf($["\${DS}" = ""]?unavail)
|
||||
same => n,Dial(\${DS},\${ARG2},tx)
|
||||
same => n,GotoIf($["\${SP_DIRECTION}" != "inbound" | "\${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(\${ARG1}))
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=\${SP_CNAM_PREFIX}\${GOSUB_RETVAL})
|
||||
same => n,Dial(\${DS},\${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(\${DS},\${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(\${CURL(\${SP_CNAM_URL}?ext=\${FILTER(0-9,\${ARG1})}&number=\${FILTER(0-9,\${CALLERID(num)})}&token=\${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=\${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -26,6 +26,7 @@ export function loadCase(name: string): Snapshot {
|
||||
holidays?: Snapshot['holidays'];
|
||||
announcements?: Snapshot['announcements'];
|
||||
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
|
||||
callerNames?: Snapshot['callerNames'];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
@@ -34,6 +35,7 @@ export function loadCase(name: string): Snapshot {
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: raw.today ?? '2026-10-17',
|
||||
...(raw.callerNames ? { callerNames: raw.callerNames } : {}),
|
||||
settings,
|
||||
dids: (raw.dids ?? []).map((d) => ({
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
|
||||
@@ -103,6 +103,8 @@ export type GeneratorDeps = {
|
||||
debounceMs?: number;
|
||||
/** RTP_PORT_START/END (config.ts). */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
/** The caller-name lookup (11 §6 C4). */
|
||||
callerNames?: { url: string; token: string };
|
||||
};
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
@@ -167,7 +169,11 @@ export function createGenerator(deps: GeneratorDeps) {
|
||||
restartRequired: false,
|
||||
};
|
||||
try {
|
||||
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
|
||||
const snap = {
|
||||
...(await loadSnapshot(db)),
|
||||
rtpPorts: deps.rtpPorts,
|
||||
callerNames: deps.callerNames,
|
||||
};
|
||||
const files = render(snap);
|
||||
await mkdir(join(dir, '.prev'), { recursive: true });
|
||||
const current = new Map<GeneratedFileName, string | null>();
|
||||
|
||||
@@ -205,6 +205,8 @@ export type Snapshot = {
|
||||
recordingAnnouncement: string | null;
|
||||
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
/** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */
|
||||
callerNames?: { url: string; token: string };
|
||||
};
|
||||
|
||||
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
|
||||
|
||||
@@ -29,6 +29,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
|
||||
/** Context-name parts (uuids), MOH class names. */
|
||||
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
|
||||
/** Generated SIP passwords. */
|
||||
/** The api on this machine, e.g. http://127.0.0.1:8080/api/internal/caller-name. */
|
||||
export const localUrl = check(
|
||||
/^http:\/\/(?:127\.0\.0\.1|localhost):[0-9]{1,5}\/[a-z0-9/_-]+$/,
|
||||
'local url',
|
||||
100,
|
||||
);
|
||||
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
|
||||
/** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */
|
||||
export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { watchPinChanges } from './services/vmpin.ts';
|
||||
import { startJobs } from './jobs.ts';
|
||||
import { watchWebhooks } from './services/webhooks.ts';
|
||||
import { watchEvents } from './services/events.ts';
|
||||
import { callerNameToken } from './contacts/callername.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -47,6 +48,10 @@ const generator = createGenerator({
|
||||
ami,
|
||||
dir: resolve(config.ASTERISK_CONFIG_DIR),
|
||||
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
|
||||
callerNames: {
|
||||
url: `http://127.0.0.1:${config.API_PORT}/api/internal/caller-name`,
|
||||
token: callerNameToken(config.APP_SECRET),
|
||||
},
|
||||
log: app.log,
|
||||
onApplied: (r) => {
|
||||
live.emit('message', {
|
||||
|
||||
+54
-2
@@ -3,7 +3,7 @@
|
||||
// ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes.
|
||||
import { statfs } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from './config.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
@@ -19,10 +19,16 @@ import {
|
||||
import type { Generator } from './generator/index.ts';
|
||||
import { todayIn } from './generator/load.ts';
|
||||
import type { LiveState } from './live/state.ts';
|
||||
import { cleanupContactShares } from './routes/contacts.ts';
|
||||
import { cleanupRecordings } from './routes/recordings.ts';
|
||||
import { transcribeCalls } from './ai/calls.ts';
|
||||
import { audit } from './services/audit.ts';
|
||||
import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { sendCallReports } from './services/callreport.ts';
|
||||
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
|
||||
import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { checkDirectory } from './sso/directory.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
@@ -107,6 +113,7 @@ export async function retentionCleanup(d: JobDeps) {
|
||||
await d.db
|
||||
.delete(systemEvents)
|
||||
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
|
||||
await cleanupContactShares(d.db);
|
||||
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
|
||||
}
|
||||
|
||||
@@ -237,7 +244,47 @@ export function createJobs(d: JobDeps) {
|
||||
);
|
||||
};
|
||||
|
||||
return { daily, backup, publicIpWatch, trunkWatch, diskWatch };
|
||||
/** Contrack books warmed every 5 minutes, so a ringing phone's lookup finds them (11 §6 C4). */
|
||||
const contactsWarm = async () => {
|
||||
const c = await contrackSettings(d.db);
|
||||
if (!c) return;
|
||||
const owners = await d.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.isActive, true), isNotNull(users.contrackTokenEnc)));
|
||||
for (const owner of [null, ...owners.map((o) => o.id)]) await contrackBook(d.db, c, owner);
|
||||
};
|
||||
|
||||
const callReports = async () => {
|
||||
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
const contrackLog = async () => {
|
||||
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
/** OpenID sessions refreshed at the provider; a leaver's end (12 §3.5). */
|
||||
const oidcCheck = async () => {
|
||||
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
/** Directory accounts re-read; a leaver disabled (12 §4.5). */
|
||||
const directoryCheck = async () => {
|
||||
await checkDirectory({ db: d.db, log: d.log });
|
||||
};
|
||||
|
||||
return {
|
||||
oidcCheck,
|
||||
directoryCheck,
|
||||
daily,
|
||||
backup,
|
||||
publicIpWatch,
|
||||
trunkWatch,
|
||||
diskWatch,
|
||||
callReports,
|
||||
contactsWarm,
|
||||
contrackLog,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts the jobs; returns a stop function. */
|
||||
@@ -259,6 +306,11 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('trunkWatch', j.trunkWatch);
|
||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||
run('callReports', j.callReports);
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('contrackLog', j.contrackLog);
|
||||
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
|
||||
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
|
||||
import { COOKIE, getSessionUser } from '../auth/sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange } from '../auth/sessions.ts';
|
||||
import { extensions } from '../db/schema.ts';
|
||||
import type { LiveState } from './state.ts';
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
for (const c of clients)
|
||||
void getSessionUser(app.db, c.token, false)
|
||||
.then((f) => {
|
||||
if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
|
||||
if (!f || mustChange(f) || (f.user.role === 'admin') !== c.admin)
|
||||
c.ws.close(4401, 'unauthorized');
|
||||
})
|
||||
.catch(() => {}); // database down: keep the socket, check again next time
|
||||
@@ -123,7 +123,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
const token = req.cookies[COOKIE];
|
||||
const found = token ? await getSessionUser(app.db, token) : null;
|
||||
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
|
||||
if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
|
||||
if (!found || mustChange(found)) return ws.close(4401, 'unauthorized');
|
||||
const mine = await app.db
|
||||
.select({ n: extensions.number })
|
||||
.from(extensions)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What a vendor template gets (07 §1). Templates are pure functions of this; no I/O there.
|
||||
import { asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { asc, eq, inArray } from 'drizzle-orm';
|
||||
import { phoneTimezone, type PhoneTimezone, type PhoneVendor } from '@selfphoneng/shared';
|
||||
import type { Config } from '../config.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { contacts, devices, extensions } from '../db/schema.ts';
|
||||
import { devices, extensions } from '../db/schema.ts';
|
||||
import { readBooks } from '../contacts/book.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { loadDialRules, type DialRule } from './dialplan.ts';
|
||||
|
||||
@@ -26,6 +27,8 @@ export type ProvisionContext = {
|
||||
provPassword: string;
|
||||
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
||||
phonebookUrl: string;
|
||||
/** Whose phone: their own contacts go in its directory (11 §6 C6). */
|
||||
ownerUserId: string | null;
|
||||
ntpServer: string;
|
||||
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
|
||||
dialRules: DialRule[];
|
||||
@@ -79,6 +82,7 @@ export async function loadContext(
|
||||
provUser: d.mac,
|
||||
provPassword,
|
||||
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
||||
ownerUserId: e.userId,
|
||||
ntpServer: NTP_SERVER,
|
||||
dialRules: await loadDialRules(db),
|
||||
},
|
||||
@@ -87,17 +91,17 @@ export async function loadContext(
|
||||
|
||||
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
|
||||
|
||||
/** The company phonebook (07 §2): every extension, then the company contacts, by name. */
|
||||
export async function loadPhonebook(db: Db): Promise<PhonebookEntry[]> {
|
||||
/** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then
|
||||
* the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */
|
||||
export async function loadPhonebook(
|
||||
db: Db,
|
||||
ownerUserId: string | null = null,
|
||||
): Promise<PhonebookEntry[]> {
|
||||
const exts = await db
|
||||
.select({ name: extensions.name, number: extensions.number })
|
||||
.from(extensions)
|
||||
.orderBy(asc(extensions.name));
|
||||
const company = await db
|
||||
.select({ name: contacts.name, numbers: contacts.numbers })
|
||||
.from(contacts)
|
||||
.where(isNull(contacts.ownerUserId))
|
||||
.orderBy(asc(contacts.name));
|
||||
const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's
|
||||
return [
|
||||
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
|
||||
...company.map((c) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
|
||||
provPassword: 'Pr0vPassw0rdXyZ12345',
|
||||
phonebookUrl:
|
||||
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
||||
ownerUserId: null,
|
||||
ntpServer: 'pool.ntp.org',
|
||||
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
|
||||
...over,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readFile, stat } from 'node:fs/promises';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AboutDto } from '@selfphoneng/shared';
|
||||
import { notFound } from '../errors.ts';
|
||||
import { SLOW } from '../auth/guards.ts';
|
||||
|
||||
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
|
||||
/** In the api's working directory (/app in the image), like ./public. */
|
||||
@@ -35,7 +36,7 @@ export async function aboutRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/about/source', async (_req, reply) => {
|
||||
app.get('/api/about/source', { config: SLOW }, async (_req, reply) => {
|
||||
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
|
||||
throw notFound('The source archive is only in builds made with Docker');
|
||||
return reply
|
||||
|
||||
+134
-22
@@ -1,14 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/auth/* (04 §2, §7.3).
|
||||
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ChangePasswordSchema,
|
||||
LoginSchema,
|
||||
PasswordConfirmSchema,
|
||||
TotpEnableSchema,
|
||||
APP_NAME,
|
||||
type AuthMethods,
|
||||
type AuthUser,
|
||||
type SignInWays,
|
||||
type TotpSetup,
|
||||
type TotpStatus,
|
||||
} from '@selfphoneng/shared';
|
||||
@@ -22,7 +24,11 @@ import {
|
||||
otpauthUrl,
|
||||
} from '../auth/totp.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
|
||||
import { directorySignIn, directoryWarning } from '../sso/directory.ts';
|
||||
import { ldapAuthenticate } from '../sso/ldap.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import {
|
||||
unauthorized,
|
||||
rateLimited,
|
||||
@@ -49,11 +55,32 @@ import {
|
||||
setSessionCookie,
|
||||
} from '../auth/sessions.ts';
|
||||
|
||||
/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
|
||||
export const signInWays = (
|
||||
u: { passwordHash: string | null },
|
||||
ids: { kind: 'oidc' | 'ldap'; email: string | null; groups: string[] }[],
|
||||
): SignInWays => {
|
||||
const oidc = ids.find((i) => i.kind === 'oidc');
|
||||
return {
|
||||
password: u.passwordHash !== null,
|
||||
oidc: oidc ? { email: oidc.email, groups: oidc.groups } : null,
|
||||
ldap: ids.some((i) => i.kind === 'ldap'),
|
||||
};
|
||||
};
|
||||
|
||||
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
const [ext] = await db
|
||||
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, u.id));
|
||||
const ids = await db
|
||||
.select({
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
groups: userIdentities.groups,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, u.id));
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
@@ -61,6 +88,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
role: u.role,
|
||||
mustChangePassword: u.mustChangePassword,
|
||||
extension: ext ?? null,
|
||||
signIn: signInWays(u, ids),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,13 +140,24 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return { secret, otpauthUrl: url, qr: await QRCode.toDataURL(url, { margin: 2, width: 240 }) };
|
||||
});
|
||||
|
||||
/**
|
||||
* A signed-in person's own password, asked again (two-step on/off, recovery codes, a new
|
||||
* password). Wrong ones count in the login limiter, so a stolen session can't guess it.
|
||||
*/
|
||||
async function myPasswordOk(req: FastifyRequest, hash: string | null, password: string) {
|
||||
if (isLoginBlocked(req.ip, req.user!.email)) throw rateLimited();
|
||||
if (hash && (await verifyPassword(hash, password))) return true;
|
||||
recordLoginFailure(req.ip, req.user!.email);
|
||||
return false;
|
||||
}
|
||||
|
||||
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
|
||||
adminOnly(req.user!);
|
||||
const { secret, code, password } = TotpEnableSchema.parse(req.body);
|
||||
const u = await myRow(req.user!.id);
|
||||
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
|
||||
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
|
||||
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
|
||||
if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
|
||||
const step = checkTotp(secret, code, null);
|
||||
if (step === null)
|
||||
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
|
||||
@@ -139,15 +178,15 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
/** Turning it off or making new recovery codes needs the password again (on does too). */
|
||||
async function confirmPassword(id: string, body: unknown) {
|
||||
const { password } = PasswordConfirmSchema.parse(body);
|
||||
const u = await myRow(id);
|
||||
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
|
||||
async function confirmPassword(req: FastifyRequest) {
|
||||
const { password } = PasswordConfirmSchema.parse(req.body);
|
||||
const u = await myRow(req.user!.id);
|
||||
if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
|
||||
return u;
|
||||
}
|
||||
|
||||
app.post('/api/auth/totp/disable', { preHandler: requireAuth }, async (req) => {
|
||||
await confirmPassword(req.user!.id, req.body);
|
||||
await confirmPassword(req);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
|
||||
@@ -164,7 +203,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/auth/totp/recovery-codes', { preHandler: requireAuth }, async (req) => {
|
||||
const u = await confirmPassword(req.user!.id, req.body);
|
||||
const u = await confirmPassword(req);
|
||||
if (!u.totpSecretEnc) throw badRequest('Two-step sign-in is off');
|
||||
const { codes, hashes } = newRecoveryCodes();
|
||||
await db.update(users).set({ totpRecoveryHashes: hashes }).where(eq(users.id, u.id));
|
||||
@@ -179,15 +218,61 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return { recoveryCodes: codes };
|
||||
});
|
||||
|
||||
/** The login page's choices (12 §2). Public: says nothing about any user. */
|
||||
app.get('/api/auth/methods', async (): Promise<AuthMethods> => {
|
||||
const mode = await getSetting(db, 'auth.mode');
|
||||
const oidc = await getSetting(db, 'auth.oidc');
|
||||
return {
|
||||
mode,
|
||||
ldap: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
|
||||
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const body = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
|
||||
const [user] = await db.select().from(users).where(eq(users.email, body.email));
|
||||
const ok = user?.isActive
|
||||
const { login, ...body } = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, login)) throw rateLimited();
|
||||
let [user] = await db.select().from(users).where(eq(users.email, login));
|
||||
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
|
||||
let ok = user?.passwordHash
|
||||
? await verifyPassword(user.passwordHash, body.password)
|
||||
: (await burnVerify(body.password), false);
|
||||
if (!user || !ok) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
let method: 'password' | 'ldap' = 'password';
|
||||
// Not this password here: the directory's, when it's on (12 §4.2).
|
||||
const ldap = ok ? null : await getSetting(db, 'auth.ldap');
|
||||
if (ldap?.enabled) {
|
||||
let entry;
|
||||
try {
|
||||
entry = await ldapAuthenticate(ldap, login, body.password);
|
||||
} catch (err) {
|
||||
// Counted like a wrong password: a 503 must not be a free guess at the local one.
|
||||
recordLoginFailure(req.ip, login);
|
||||
await audit(db, {
|
||||
actorUserId: user?.id,
|
||||
actorIp: req.ip,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
summary: `Failed login for ${login} (the directory can't be reached)`,
|
||||
});
|
||||
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
|
||||
await directoryWarning(
|
||||
db,
|
||||
`A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
|
||||
);
|
||||
throw new HttpError(
|
||||
503,
|
||||
'directory_unavailable',
|
||||
'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
|
||||
);
|
||||
}
|
||||
const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
|
||||
if (entry && !found)
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
|
||||
if (found) [user, ok, method] = [found, true, 'ldap'];
|
||||
}
|
||||
if (!user || !ok || !user.isActive) {
|
||||
recordLoginFailure(req.ip, login);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
|
||||
await audit(db, {
|
||||
actorUserId: user?.id,
|
||||
@@ -195,16 +280,19 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
summary: `Failed login for ${body.email}`,
|
||||
summary: `Failed login for ${login}`,
|
||||
});
|
||||
throw unauthorized('Wrong email or password');
|
||||
}
|
||||
// OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
|
||||
if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
|
||||
throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
|
||||
// Two-step sign-in (08 §2.4): the password was right; now the code.
|
||||
if (user.totpSecretEnc) {
|
||||
if (!body.totp)
|
||||
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
|
||||
if (!(await useSecondFactor(user, body.totp))) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
recordLoginFailure(req.ip, login);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
|
||||
await audit(db, {
|
||||
actorUserId: user.id,
|
||||
@@ -212,12 +300,18 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
|
||||
summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
|
||||
});
|
||||
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
|
||||
}
|
||||
}
|
||||
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
|
||||
const token = await createSession(
|
||||
db,
|
||||
user.id,
|
||||
req.ip,
|
||||
req.headers['user-agent'] ?? null,
|
||||
method,
|
||||
);
|
||||
setSessionCookie(reply, token);
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
|
||||
await audit(db, {
|
||||
@@ -226,14 +320,30 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `${user.email} logged in`,
|
||||
summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
|
||||
});
|
||||
return { user: await toAuthUser(db, user) };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const [sess] = req.sessionId
|
||||
? await db.select().from(sessions).where(eq(sessions.id, req.sessionId))
|
||||
: [];
|
||||
await deleteSession(db, req.cookies[COOKIE]!);
|
||||
clearSessionCookie(reply);
|
||||
// Signed in with OpenID: sign out there too (12 §3.5). A provider problem doesn't stop ours.
|
||||
let redirect: string | null = null;
|
||||
const oidc = sess?.idpIdTokenEnc ? await getSetting(db, 'auth.oidc') : null;
|
||||
if (oidc)
|
||||
try {
|
||||
redirect = await endSessionUrl(
|
||||
oidc,
|
||||
app.config.PUBLIC_HOSTNAME,
|
||||
decrypt(sess!.idpIdTokenEnc!),
|
||||
);
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'no sign-out at the provider');
|
||||
}
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
@@ -242,7 +352,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
entityId: req.user!.id,
|
||||
summary: `${req.user!.email} logged out`,
|
||||
});
|
||||
return { ok: true };
|
||||
return redirect ? { ok: true, redirect } : { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
|
||||
@@ -252,7 +362,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
|
||||
const body = ChangePasswordSchema.parse(req.body);
|
||||
const user = req.user!;
|
||||
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
|
||||
if (!user.passwordHash)
|
||||
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
|
||||
if (!(await myPasswordOk(req, user.passwordHash, body.currentPassword)))
|
||||
throw badRequest('Current password is wrong');
|
||||
if (body.newPassword === body.currentPassword)
|
||||
throw badRequest('Choose a password different from the current one');
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/me/contacts (my book, shares) and /api/contacts (the company book, admin): spec 11.
|
||||
import { and, desc, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ContactImportSchema,
|
||||
ContactInputSchema,
|
||||
ContactShareDecisionSchema,
|
||||
ContactShareSchema,
|
||||
type ContactBookDto,
|
||||
type ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
type ContactShareDto,
|
||||
type ListResponse,
|
||||
} from '@selfphoneng/shared';
|
||||
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import {
|
||||
addToBook,
|
||||
assertBuiltin,
|
||||
cleanNumbers,
|
||||
contactNames,
|
||||
copyToContrack,
|
||||
createContact,
|
||||
readBooks,
|
||||
toDto,
|
||||
type Owner,
|
||||
} from '../contacts/book.ts';
|
||||
import { parseCsv } from '../contacts/csv.ts';
|
||||
import { parseVcards } from '../contacts/vcard.ts';
|
||||
import { contactShares, contacts, extensions, users } from '../db/schema.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { contrackCheck } from '../contacts/contrack.ts';
|
||||
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
|
||||
/** 5,000 contacts or 5 MB of files per import (11 §3.1). */
|
||||
const MAX_IMPORT = 5000;
|
||||
const IMPORT_BODY = 6 * 1024 * 1024;
|
||||
|
||||
/** Every contact in the files, and what couldn't be read. */
|
||||
function readFiles(files: { name: string; text: string }[]) {
|
||||
const list: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const f of files) {
|
||||
const vcf = /\.vcf$/i.test(f.name) || /^\s*BEGIN:VCARD/i.test(f.text);
|
||||
const r = vcf ? parseVcards(f.text) : parseCsv(f.text);
|
||||
list.push(...r.contacts);
|
||||
errors.push(...r.errors.map((e) => `${f.name}: ${e}`));
|
||||
}
|
||||
if (list.length > MAX_IMPORT) throw badRequest(`At most ${MAX_IMPORT} contacts per import`);
|
||||
return { list, errors };
|
||||
}
|
||||
|
||||
export async function contactRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
|
||||
// ───── the routes both books have; `owner` says whose ─────
|
||||
const book = (
|
||||
prefix: string,
|
||||
guard: typeof requireAuth,
|
||||
ownerOf: (u: { id: string }) => Owner,
|
||||
) => {
|
||||
const mine = (u: { id: string }, id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.id, id),
|
||||
ownerOf(u) ? eq(contacts.ownerUserId, ownerOf(u)!) : isNull(contacts.ownerUserId),
|
||||
),
|
||||
)
|
||||
.then(([r]) => {
|
||||
if (!r) throw notFound('Contact not found');
|
||||
return r;
|
||||
});
|
||||
const company = (u: { id: string }) => ownerOf(u) === null;
|
||||
const log = (
|
||||
req: { user: { id: string } | null; ip: string },
|
||||
action: 'create' | 'update' | 'delete',
|
||||
id: string | undefined,
|
||||
summary: string,
|
||||
) =>
|
||||
company(req.user!)
|
||||
? audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action,
|
||||
entityType: 'contact',
|
||||
entityId: id,
|
||||
summary,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
app.post(prefix, { preHandler: guard }, async (req, reply) => {
|
||||
const c = ContactInputSchema.parse(req.body);
|
||||
const dto = await createContact(db, ownerOf(req.user!), c);
|
||||
await log(req, 'create', dto.id, `Added ${c.name} to the company contacts`);
|
||||
return reply.code(201).send(dto);
|
||||
});
|
||||
|
||||
app.patch(`${prefix}/:id`, { preHandler: guard }, async (req): Promise<ContactDto> => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
await assertBuiltin(db);
|
||||
await mine(req.user!, id);
|
||||
const c = ContactInputSchema.parse(req.body);
|
||||
const [row] = await db
|
||||
.update(contacts)
|
||||
.set({ ...c, numbers: cleanNumbers(c), updatedAt: new Date() })
|
||||
.where(eq(contacts.id, id))
|
||||
.returning();
|
||||
await log(req, 'update', id, `Changed the company contact ${c.name}`);
|
||||
return toDto(row!);
|
||||
});
|
||||
|
||||
app.delete(`${prefix}/:id`, { preHandler: guard }, async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
await assertBuiltin(db);
|
||||
const r = await mine(req.user!, id);
|
||||
await db.delete(contacts).where(eq(contacts.id, id));
|
||||
await log(req, 'delete', id, `Deleted the company contact ${r.name}`);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post(
|
||||
`${prefix}/import`,
|
||||
{ preHandler: guard, bodyLimit: IMPORT_BODY },
|
||||
async (req): Promise<ContactImportResult> => {
|
||||
await assertBuiltin(db); // Contrack has its own importer (11 §3.1)
|
||||
const { files } = ContactImportSchema.parse(req.body);
|
||||
const commit = CommitQuery.parse(req.query).commit === '1';
|
||||
const { list, errors } = readFiles(files);
|
||||
const r = await addToBook(db, ownerOf(req.user!), list, commit, errors);
|
||||
if (commit && r.added)
|
||||
await log(req, 'create', undefined, `Imported ${r.added} company contacts`);
|
||||
return r;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// ───── my book ─────
|
||||
/** Mine and the company's: the page filters, and the browser looks up names from it. */
|
||||
app.get('/api/me/contacts', { preHandler: requireAuth }, async (req): Promise<ContactBookDto> =>
|
||||
readBooks(db, req.user!.id),
|
||||
);
|
||||
book('/api/me/contacts', requireAuth, (u) => u.id);
|
||||
|
||||
// ───── the company book ─────
|
||||
app.get('/api/contacts', { preHandler: requireAdmin }, async (): Promise<ContactBookDto> =>
|
||||
readBooks(db, null),
|
||||
);
|
||||
book('/api/contacts', requireAdmin, () => null);
|
||||
|
||||
/**
|
||||
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
|
||||
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
|
||||
*/
|
||||
app.get(
|
||||
'/api/internal/caller-name',
|
||||
{ logLevel: 'silent', config: NO_LIMIT },
|
||||
async (req, reply) => {
|
||||
const q = z
|
||||
.object({
|
||||
ext: z.string().regex(/^\d{1,10}$/),
|
||||
number: z.string().regex(/^\d{3,20}$/),
|
||||
token: z.string().max(100),
|
||||
})
|
||||
.safeParse(req.query);
|
||||
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(
|
||||
req.socket.remoteAddress ?? '',
|
||||
);
|
||||
if (
|
||||
!local ||
|
||||
req.headers['x-forwarded-for'] ||
|
||||
!q.success ||
|
||||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
|
||||
)
|
||||
return reply.code(404).send('');
|
||||
const [e] = await db
|
||||
.select({ userId: extensions.userId })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.number, q.data.ext));
|
||||
const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
|
||||
wait: false,
|
||||
}).catch(() => new Map<string, string>());
|
||||
return reply
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(phoneSafeName(names.get(q.data.number) ?? ''));
|
||||
},
|
||||
);
|
||||
|
||||
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
|
||||
app.post(
|
||||
'/api/me/contacts/copy-to-contrack',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ContactCopyResult> => copyToContrack(db, req.user!.id),
|
||||
);
|
||||
app.post(
|
||||
'/api/contacts/copy-to-contrack',
|
||||
{ preHandler: requireAdmin },
|
||||
async (req): Promise<ContactCopyResult> => {
|
||||
const r = await copyToContrack(db, null);
|
||||
if (r.added)
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'contact',
|
||||
summary: `Copied ${r.added} company contacts to Contrack`,
|
||||
});
|
||||
return r;
|
||||
},
|
||||
);
|
||||
|
||||
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
|
||||
app.post(
|
||||
'/api/contacts/contrack/test',
|
||||
{ preHandler: requireAdmin, config: SLOW },
|
||||
async (req) => {
|
||||
const b = z
|
||||
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
|
||||
.parse(req.body);
|
||||
const saved = await getSetting(db, 'contacts.contrack');
|
||||
const token =
|
||||
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
|
||||
try {
|
||||
return { ok: true, detail: await contrackCheck(b.url, token) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
|
||||
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
|
||||
const s = ContactShareSchema.parse(req.body);
|
||||
const me = req.user!.id;
|
||||
const picked = (await readBooks(db, me)).items.filter(
|
||||
(c) => c.book === 'mine' && s.contactIds.includes(c.id),
|
||||
);
|
||||
if (picked.length !== new Set(s.contactIds).size) throw notFound('Contact not found');
|
||||
const to = s.everyone
|
||||
? await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.isActive, true), ne(users.id, me)))
|
||||
: await db
|
||||
.select({ id: users.id })
|
||||
.from(extensions)
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.where(
|
||||
and(
|
||||
inArray(extensions.number, s.extensions),
|
||||
eq(users.isActive, true),
|
||||
ne(users.id, me),
|
||||
),
|
||||
);
|
||||
if (!to.length) throw badRequest('Nobody to share with');
|
||||
await db.transaction(async (tx) => {
|
||||
// The same contact offered again replaces the pending offer.
|
||||
await tx.delete(contactShares).where(
|
||||
and(
|
||||
eq(contactShares.status, 'pending'),
|
||||
inArray(
|
||||
contactShares.toUserId,
|
||||
to.map((u) => u.id),
|
||||
),
|
||||
inArray(
|
||||
contactShares.sourceId,
|
||||
picked.map((c) => c.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tx.insert(contactShares).values(
|
||||
to.flatMap((u) =>
|
||||
picked.map(({ id, name, company, email, numbers, notes }) => ({
|
||||
fromUserId: me,
|
||||
toUserId: u.id,
|
||||
sourceId: id,
|
||||
contact: { name, company, email, numbers, notes },
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
return { offers: picked.length * to.length, people: to.length };
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/me/contacts/shares',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ListResponse<ContactShareDto>> => {
|
||||
const rows = await db
|
||||
.select({ s: contactShares, fromName: users.displayName })
|
||||
.from(contactShares)
|
||||
.leftJoin(users, eq(users.id, contactShares.fromUserId))
|
||||
.where(and(eq(contactShares.toUserId, req.user!.id), eq(contactShares.status, 'pending')))
|
||||
.orderBy(desc(contactShares.createdAt));
|
||||
return {
|
||||
items: rows.map(({ s, fromName }) => ({
|
||||
id: s.id,
|
||||
from: s.fromUserId && fromName ? { userId: s.fromUserId, name: fromName } : null,
|
||||
contact: s.contact as ContactData,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
for (const decision of ['accept', 'deny'] as const)
|
||||
app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => {
|
||||
const { ids } = ContactShareDecisionSchema.parse(req.body);
|
||||
const me = req.user!.id;
|
||||
const mineAndPending = and(
|
||||
eq(contactShares.toUserId, me),
|
||||
eq(contactShares.status, 'pending'),
|
||||
inArray(contactShares.id, ids),
|
||||
);
|
||||
const offers = await db.select().from(contactShares).where(mineAndPending);
|
||||
// Added first, then marked: Contrack being down (or not connected) leaves the offers waiting.
|
||||
const r =
|
||||
decision === 'accept'
|
||||
? await addToBook(
|
||||
db,
|
||||
me,
|
||||
offers.map((o) => o.contact as ContactData),
|
||||
true,
|
||||
)
|
||||
: null;
|
||||
await db
|
||||
.update(contactShares)
|
||||
.set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
mineAndPending,
|
||||
inArray(
|
||||
contactShares.id,
|
||||
offers.map((o) => o.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length };
|
||||
});
|
||||
}
|
||||
|
||||
/** Decided offers go after 30 days (11 §2.1); retentionCleanup calls it. */
|
||||
export const cleanupContactShares = (db: FastifyInstance['db']) =>
|
||||
db
|
||||
.delete(contactShares)
|
||||
.where(
|
||||
and(
|
||||
ne(contactShares.status, 'pending'),
|
||||
lt(contactShares.decidedAt, sql`now() - interval '30 days'`),
|
||||
),
|
||||
);
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type WebhookDto,
|
||||
type WebhookEvent,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { generateToken, hashToken } from '../auth/tokens.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
|
||||
@@ -103,7 +103,7 @@ export async function integrationRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
|
||||
app.post('/api/webhooks/:id/test', async (req) => {
|
||||
app.post('/api/webhooks/:id/test', { config: SLOW }, async (req) => {
|
||||
const h = await getHook(IdParams.parse(req.params).id);
|
||||
const body = JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
|
||||
+103
-3
@@ -1,12 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3): the signed-in user's own mailbox.
|
||||
// /api/me (settings, call report), /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3).
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ContrackOptionsSchema,
|
||||
GREETINGS,
|
||||
MeUpdateSchema,
|
||||
type ListResponse,
|
||||
@@ -27,8 +28,16 @@ import {
|
||||
queueMembers,
|
||||
queues,
|
||||
userRuntimeState,
|
||||
users,
|
||||
voicemailMessages,
|
||||
} from '../db/schema.ts';
|
||||
import {
|
||||
contrackBook,
|
||||
contrackCheck,
|
||||
contrackSettings,
|
||||
forgetBook,
|
||||
tokenFor,
|
||||
} from '../contacts/contrack.ts';
|
||||
import { setPaused } from '../services/queues.ts';
|
||||
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
|
||||
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
|
||||
@@ -111,9 +120,91 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
voicemailEmail: e.voicemailEmail,
|
||||
voicemailAttachAudio: e.voicemailAttachAudio,
|
||||
voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail,
|
||||
callReport: e.callReport,
|
||||
callReportTime: e.callReportTime,
|
||||
callReportWeekday: e.callReportWeekday,
|
||||
callReportSummaries: e.callReportSummaries,
|
||||
contrack: await myContrack(userId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Contrack mode: is my token set, and does it work (11 §4.3). Null in builtin mode. */
|
||||
async function myContrack(userId: string): Promise<MeDto['contrack']> {
|
||||
const c = await contrackSettings(db);
|
||||
if (!c) return null;
|
||||
const token = await tokenFor(db, c, userId);
|
||||
const book = token ? await contrackBook(db, c, userId) : null;
|
||||
const [u] = await db
|
||||
.select({ logCalls: users.contrackLogCalls, logSummaries: users.contrackLogSummaries })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
return {
|
||||
connected: !!book && !book.error,
|
||||
tokenPrefix: token ? `${token.slice(0, 12)}…` : null,
|
||||
publicUrl: c.publicUrl,
|
||||
logCalls: u?.logCalls ?? true,
|
||||
logSummaries: u?.logSummaries ?? false,
|
||||
summariesAvailable:
|
||||
(await getSetting(db, 'ai.callTranscripts')) && (await getSetting(db, 'ai.callSummaries')),
|
||||
};
|
||||
}
|
||||
|
||||
/** Connect my Contrack account: the token must read my contacts before it's kept. */
|
||||
app.put('/api/me/contrack', async (req) => {
|
||||
const { token } = z.object({ token: z.string().trim().min(8).max(200) }).parse(req.body);
|
||||
const c = await contrackSettings(db);
|
||||
if (!c) throw conflict('Contacts are not in Contrack (Settings → Contacts)');
|
||||
await contrackCheck(c.url, token).catch((err: Error) => {
|
||||
throw badRequest(err.message);
|
||||
});
|
||||
// Calls are logged from now on, not the ones before (11 §4.4).
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
contrackTokenEnc: encrypt(token),
|
||||
contrackLogSince: sql`coalesce(${users.contrackLogSince}, now())`,
|
||||
})
|
||||
.where(eq(users.id, req.user!.id));
|
||||
forgetBook(req.user!.id);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: req.user!.id,
|
||||
summary: 'Connected their Contrack account',
|
||||
});
|
||||
return myState(req.user!.id);
|
||||
});
|
||||
|
||||
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
|
||||
app.patch('/api/me/contrack', async (req) => {
|
||||
const o = ContrackOptionsSchema.parse(req.body);
|
||||
// An empty update would be `UPDATE … SET` with nothing to set: a 500, not a 400.
|
||||
if (o.logCalls === undefined && o.logSummaries === undefined)
|
||||
throw badRequest('Nothing to update');
|
||||
const [u] = await db
|
||||
.select({ logCalls: users.contrackLogCalls })
|
||||
.from(users)
|
||||
.where(eq(users.id, req.user!.id));
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
...(o.logCalls !== undefined ? { contrackLogCalls: o.logCalls } : {}),
|
||||
...(o.logSummaries !== undefined ? { contrackLogSummaries: o.logSummaries } : {}),
|
||||
// Turned back on: from now, not the calls while it was off.
|
||||
...(o.logCalls && !u?.logCalls ? { contrackLogSince: new Date() } : {}),
|
||||
})
|
||||
.where(eq(users.id, req.user!.id));
|
||||
return myState(req.user!.id);
|
||||
});
|
||||
|
||||
app.delete('/api/me/contrack', async (req) => {
|
||||
await db.update(users).set({ contrackTokenEnc: null }).where(eq(users.id, req.user!.id));
|
||||
forgetBook(req.user!.id);
|
||||
return myState(req.user!.id);
|
||||
});
|
||||
|
||||
app.get('/api/me', async (req) => myState(req.user!.id));
|
||||
|
||||
// Queue login from Home (05 §4, 04 §7.3), the same as *45<queue> on a phone.
|
||||
@@ -144,6 +235,11 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
if (body.voicemailPin && isTrivialPin(body.voicemailPin, ext.number))
|
||||
throw badRequest('That PIN is too easy to guess (repeated, sequential or your extension)');
|
||||
const { dnd: _d, cfu: _u, cfb: _b, cfna: _n, voicemailPin, ...vm } = body;
|
||||
// A new call report schedule starts with its next period, not one that just ended.
|
||||
const rescheduled =
|
||||
body.callReport !== undefined ||
|
||||
body.callReportTime !== undefined ||
|
||||
body.callReportWeekday !== undefined;
|
||||
await db.transaction(async (tx) => {
|
||||
if (Object.keys(runtime).length)
|
||||
await tx
|
||||
@@ -153,7 +249,11 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
if (Object.keys(vm).length || voicemailPin)
|
||||
await tx
|
||||
.update(extensions)
|
||||
.set({ ...vm, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) })
|
||||
.set({
|
||||
...vm,
|
||||
...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}),
|
||||
...(rescheduled ? { callReportSentAt: new Date() } : {}),
|
||||
})
|
||||
.where(eq(extensions.id, ext.id));
|
||||
});
|
||||
// Offline Asterisk catches up from PostgreSQL on its next connect.
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
|
||||
import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import {
|
||||
HttpError,
|
||||
badRequest,
|
||||
forbidden,
|
||||
notFound,
|
||||
rateLimited,
|
||||
unauthorized,
|
||||
} from '../errors.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
|
||||
import { createSession, setSessionCookie } from '../auth/sessions.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { decide, verifiedEmail } from '../sso/claims.ts';
|
||||
import { connectDirectory } from '../sso/directory.ts';
|
||||
import { ldapAuthenticate } from '../sso/ldap.ts';
|
||||
import { applyClaims } from '../sso/oidcCheck.ts';
|
||||
import {
|
||||
finishSignIn,
|
||||
oidcError,
|
||||
startSignIn,
|
||||
type OidcSettings,
|
||||
type PendingSignIn,
|
||||
} from '../sso/oidc.ts';
|
||||
|
||||
export const OIDC_COOKIE = 'sp_oidc';
|
||||
const COOKIE_PATH = '/api/auth/oidc';
|
||||
|
||||
/**
|
||||
* Same-site paths only: never send the browser to another origin. No backslash or control
|
||||
* character anywhere: browsers drop tabs and newlines from URLs, so "/<tab>/evil.example"
|
||||
* would become "//evil.example".
|
||||
*/
|
||||
export const safeNext = (p: string | undefined) =>
|
||||
// eslint-disable-next-line no-control-regex -- control characters are what it refuses
|
||||
p && /^\/(?!\/)[^\\\x00-\x1f\x7f]*$/.test(p) ? p : undefined;
|
||||
|
||||
const withParam = (path: string, k: string, v: string) =>
|
||||
`${path}${path.includes('?') ? '&' : '?'}${k}=${v}`;
|
||||
|
||||
export async function oidcRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
const host = app.config.PUBLIC_HOSTNAME;
|
||||
|
||||
/** The provider's settings, when OpenID is on (12 §1.1: not in `password` mode). */
|
||||
async function enabled(): Promise<OidcSettings> {
|
||||
const s = await getSetting(db, 'auth.oidc');
|
||||
if (!s || (await getSetting(db, 'auth.mode')) === 'password') throw notFound();
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The sp_oidc cookie: what the callback checks the provider's answer against. */
|
||||
async function begin(reply: FastifyReply, s: OidcSettings, next: string, connect: string | null) {
|
||||
const { url, pending } = await startSignIn(s, host, next, connect);
|
||||
reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
path: COOKIE_PATH,
|
||||
maxAge: 600,
|
||||
});
|
||||
return url.href;
|
||||
}
|
||||
|
||||
app.get('/api/auth/oidc/start', async (req, reply) => {
|
||||
const s = await enabled();
|
||||
const q = z.object({ next: z.string().max(500).optional() }).parse(req.query);
|
||||
try {
|
||||
return reply.redirect(await begin(reply, s, safeNext(q.next) ?? '/', null));
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
|
||||
return reply.redirect(withParam('/login', 'sso_error', 'provider'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Connect from My account (12 §3.3). A POST, so it needs the CSRF header: another site can't
|
||||
* start it and connect whatever provider account the browser is signed in to. The page then
|
||||
* sends the browser to `url`.
|
||||
*/
|
||||
app.post('/api/auth/oidc/connect', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const s = await enabled();
|
||||
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
|
||||
const q = z.object({ next: z.string().max(500).optional() }).parse(req.body ?? {});
|
||||
const next =
|
||||
safeNext(q.next) ?? (req.user!.role === 'admin' ? '/admin/account' : '/me/settings');
|
||||
try {
|
||||
return { url: await begin(reply, s, next, req.sessionId) };
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
|
||||
throw new HttpError(502, 'provider', `${s.label} can't be reached`);
|
||||
}
|
||||
});
|
||||
|
||||
// Not logged by Fastify: the query holds the authorization code (12 §1).
|
||||
app.get('/api/auth/oidc/callback', { logLevel: 'silent' }, async (req, reply) => {
|
||||
const s = await enabled();
|
||||
let pending: PendingSignIn | null = null;
|
||||
try {
|
||||
pending = JSON.parse(decrypt(req.cookies[OIDC_COOKIE] ?? '')) as PendingSignIn;
|
||||
} catch {
|
||||
// missing or not ours: `expired` below
|
||||
}
|
||||
reply.clearCookie(OIDC_COOKIE, { path: COOKIE_PATH });
|
||||
const connecting = pending?.connectSessionId ?? null;
|
||||
|
||||
const fail = async (code: string, reason: string, userId?: string) => {
|
||||
if (!connecting) recordLoginFailure(req.ip, null);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip, reason }, 'OpenID sign-in failed');
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: req.ip,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Failed ${s.label} sign-in (${code}: ${reason.slice(0, 200)})`,
|
||||
});
|
||||
return reply.redirect(withParam(connecting ? pending!.next : '/login', 'sso_error', code));
|
||||
};
|
||||
|
||||
if (!connecting && isLoginBlocked(req.ip, null)) return fail('too_many', 'rate limited');
|
||||
if (!pending || pending.exp < Date.now()) return fail('expired', 'no sign-in in progress');
|
||||
const q = req.query as Record<string, unknown>;
|
||||
if (typeof q.error === 'string')
|
||||
return fail(q.error === 'access_denied' ? 'denied' : 'provider', `provider: ${q.error}`);
|
||||
|
||||
let r;
|
||||
try {
|
||||
r = await finishSignIn(s, new URL(`https://${host}${req.url}`), pending);
|
||||
} catch (err) {
|
||||
return fail('provider', oidcError(err));
|
||||
}
|
||||
const d = decide(s, r.claims);
|
||||
const email = typeof r.claims.email === 'string' ? r.claims.email : null;
|
||||
if (!d.allowed) return fail('not_allowed', `${email ?? r.subject} not in the allowed groups`);
|
||||
const [owner] = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
|
||||
|
||||
if (connecting) {
|
||||
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
|
||||
const [sess] = await db
|
||||
.select({ userId: sessions.userId, email: users.email })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.id, connecting),
|
||||
gt(sessions.expiresAt, new Date()),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!sess) return fail('expired', 'the session that asked has ended');
|
||||
if (owner && owner.userId !== sess.userId)
|
||||
return fail('taken', `${email ?? r.subject} belongs to another user`, sess.userId);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, sess.userId), eq(userIdentities.kind, 'oidc')));
|
||||
await tx.insert(userIdentities).values({
|
||||
userId: sess.userId,
|
||||
kind: 'oidc',
|
||||
subject: r.subject,
|
||||
email,
|
||||
groups: d.groups,
|
||||
});
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: sess.userId,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_connected',
|
||||
entityType: 'user',
|
||||
entityId: sess.userId,
|
||||
summary: `${sess.email} connected ${s.label} account ${email ?? r.subject}`,
|
||||
});
|
||||
return reply.redirect(withParam(pending.next, 'sso', 'connected'));
|
||||
}
|
||||
|
||||
let userId = owner?.userId;
|
||||
if (!userId) {
|
||||
// A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
|
||||
const v = verifiedEmail(r.claims, s.trustEmail);
|
||||
const [u] = v
|
||||
? await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, v),
|
||||
isNull(users.passwordHash),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: userIdentities.id })
|
||||
.from(userIdentities)
|
||||
.where(
|
||||
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (!u) return fail('not_connected', `${email ?? r.subject} isn't connected to a user`);
|
||||
await db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: u.id, kind: 'oidc', subject: r.subject, email, groups: d.groups });
|
||||
await audit(db, {
|
||||
actorUserId: u.id,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_connected',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${v} connected ${s.label} by verified email`,
|
||||
});
|
||||
userId = u.id;
|
||||
}
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user!.isActive) return fail('disabled', `${user!.email} is disabled`, userId);
|
||||
|
||||
await applyClaims(db, req.log, s, userId, d, email, req.ip);
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
const token = await createSession(
|
||||
db,
|
||||
userId,
|
||||
req.ip,
|
||||
req.headers['user-agent'] ?? null,
|
||||
'oidc',
|
||||
{ refreshToken: r.refreshToken, idToken: r.idToken },
|
||||
);
|
||||
setSessionCookie(reply, token);
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId));
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: req.ip,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `${user!.email} signed in with ${s.label}`,
|
||||
});
|
||||
req.log.info({ event: 'auth.oidc', userId }, 'OpenID sign-in');
|
||||
return reply.redirect(pending.next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Disconnect an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
|
||||
* them no way in under the current mode. Sessions made with it end.
|
||||
*/
|
||||
async function disconnect(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
userId: string,
|
||||
kind: 'oidc' | 'ldap',
|
||||
) {
|
||||
const [u] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!u) throw notFound('User not found');
|
||||
const ids = await db
|
||||
.select({ kind: userIdentities.kind })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, userId));
|
||||
if (!ids.some((i) => i.kind === kind)) throw notFound('No such account is connected');
|
||||
const left = ids.filter((i) => i.kind !== kind);
|
||||
const mode = await getSetting(db, 'auth.mode');
|
||||
const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
|
||||
const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
|
||||
if (!openid && !(password && (mode !== 'oidc' || u.role === 'admin')))
|
||||
throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, kind)));
|
||||
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
|
||||
const what = kind === 'oidc' ? 'OpenID' : 'directory';
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_disconnected',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Disconnected the ${what} account of ${u.email}`,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
}
|
||||
|
||||
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
|
||||
app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) => {
|
||||
const { kind } = Kind.parse(req.params);
|
||||
// The directory account is how a leaver gets disabled (12 §4.5): only an admin drops it.
|
||||
if (kind === 'ldap' && req.user!.role !== 'admin')
|
||||
throw forbidden('Ask an admin to disconnect your directory account', 'admin_only');
|
||||
return disconnect(req, reply, req.user!.id, kind);
|
||||
});
|
||||
|
||||
/**
|
||||
* Connect my directory account (12 §4.3): its username and password, checked by the directory,
|
||||
* while signed in here. Counted like a sign-in by the login limiter.
|
||||
*/
|
||||
app.post('/api/me/identities/ldap', { preHandler: requireAuth }, async (req) => {
|
||||
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
|
||||
const { login, password } = z
|
||||
.object({ login: z.string().trim().min(1).max(254), password: z.string().min(1).max(200) })
|
||||
.parse(req.body);
|
||||
const ldap = await getSetting(db, 'auth.ldap');
|
||||
if (!ldap?.enabled) throw notFound();
|
||||
if (isLoginBlocked(req.ip, login)) throw rateLimited();
|
||||
let entry;
|
||||
try {
|
||||
entry = await ldapAuthenticate(ldap, login, password);
|
||||
} catch {
|
||||
recordLoginFailure(req.ip, login);
|
||||
throw new HttpError(503, 'directory_unavailable', 'The directory can’t be reached');
|
||||
}
|
||||
if (!entry) {
|
||||
recordLoginFailure(req.ip, login);
|
||||
throw badRequest('The directory refused that username or password');
|
||||
}
|
||||
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
|
||||
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
|
||||
return { ok: true };
|
||||
});
|
||||
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
|
||||
const p = Kind.extend({ id: z.uuid() }).parse(req.params);
|
||||
return disconnect(req, reply, p.id, p.kind);
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts'
|
||||
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
|
||||
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { NO_LIMIT } from '../auth/guards.ts';
|
||||
|
||||
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
|
||||
const FILES: {
|
||||
@@ -66,14 +67,14 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
/** The config, or (phonebook) the directory in the phone's vendor's format. */
|
||||
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
|
||||
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
|
||||
const entries = await loadPhonebook(db);
|
||||
const entries = await loadPhonebook(db, c.ownerUserId);
|
||||
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
|
||||
}
|
||||
|
||||
const deny = (reply: FastifyReply, code: number, text: string) =>
|
||||
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
|
||||
|
||||
app.get('/prov/*', async (req, reply) => {
|
||||
app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
|
||||
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
|
||||
const local = await isLocal(req.ip);
|
||||
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
|
||||
|
||||
@@ -6,10 +6,17 @@ import { z } from 'zod';
|
||||
import {
|
||||
AiEndpointSchema,
|
||||
AiTtsEndpointSchema,
|
||||
ContrackInputSchema,
|
||||
LdapInputSchema,
|
||||
OidcInputSchema,
|
||||
SettingsSchemas,
|
||||
SmtpInputSchema,
|
||||
type AiTestResult,
|
||||
type AuthMode,
|
||||
type Destination,
|
||||
type LdapTestResult,
|
||||
type OidcInput,
|
||||
type OidcTestResult,
|
||||
type SettingKey,
|
||||
type SettingsDto,
|
||||
type Warning,
|
||||
@@ -17,7 +24,7 @@ import {
|
||||
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 { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists } from '../services/destinations.ts';
|
||||
@@ -28,6 +35,10 @@ 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';
|
||||
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
|
||||
import { directoryUserFor } from '../sso/directory.ts';
|
||||
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -40,14 +51,51 @@ declare module 'fastify' {
|
||||
const READ_ONLY = new Set<string>([
|
||||
'system.restartRequired',
|
||||
'system.transportHash',
|
||||
'system.directoryWarning',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
]);
|
||||
|
||||
async function toDto(db: Db): Promise<SettingsDto> {
|
||||
const { smtp, ...rest } = await getAllSettings(db);
|
||||
const {
|
||||
smtp,
|
||||
'contacts.contrack': ctk,
|
||||
'auth.oidc': oidc,
|
||||
'auth.ldap': ldap,
|
||||
...rest
|
||||
} = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
'auth.ldap': ldap && {
|
||||
enabled: ldap.enabled,
|
||||
preset: ldap.preset,
|
||||
url: ldap.url,
|
||||
startTls: ldap.startTls,
|
||||
bindDn: ldap.bindDn,
|
||||
baseDn: ldap.baseDn,
|
||||
userFilter: ldap.userFilter,
|
||||
idAttribute: ldap.idAttribute,
|
||||
emailAttribute: ldap.emailAttribute,
|
||||
nameAttribute: ldap.nameAttribute,
|
||||
adminFilter: ldap.adminFilter,
|
||||
hasBindPassword: ldap.bindPasswordEnc !== null,
|
||||
},
|
||||
'auth.oidc': oidc && {
|
||||
label: oidc.label,
|
||||
issuer: oidc.issuer,
|
||||
clientId: oidc.clientId,
|
||||
scopes: oidc.scopes,
|
||||
groupsClaim: oidc.groupsClaim,
|
||||
adminGroups: oidc.adminGroups,
|
||||
allowedGroups: oidc.allowedGroups,
|
||||
trustEmail: oidc.trustEmail,
|
||||
hasClientSecret: oidc.clientSecretEnc !== null,
|
||||
},
|
||||
'contacts.contrack': ctk && {
|
||||
url: ctk.url,
|
||||
publicUrl: ctk.publicUrl,
|
||||
hasCompanyToken: ctk.companyTokenEnc !== '',
|
||||
},
|
||||
smtp: smtp && {
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
@@ -93,6 +141,74 @@ async function checkRefs(db: Db, v: Record<string, unknown>) {
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
|
||||
/** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
|
||||
async function ldapFromInput(raw: unknown): Promise<LdapSettings | null> {
|
||||
if (raw === null) return null;
|
||||
const v = LdapInputSchema.parse(raw);
|
||||
try {
|
||||
assertLdap(v);
|
||||
} catch (err) {
|
||||
throw badRequest((err as Error).message);
|
||||
}
|
||||
const old = await getSetting(db, 'auth.ldap');
|
||||
const url = v.url.replace(/\/$/, '');
|
||||
// The stored password only goes back to the server and account it was typed for: else a
|
||||
// changed address (or Test) would send it to whoever answers there.
|
||||
const same = old?.url === url && old.bindDn === v.bindDn;
|
||||
if (!v.bindPassword && !v.clearBindPassword && !same && v.bindDn && old?.bindPasswordEnc)
|
||||
throw badRequest('The address or service account changed: enter its password again');
|
||||
return {
|
||||
enabled: v.enabled,
|
||||
preset: v.preset,
|
||||
url,
|
||||
startTls: v.startTls,
|
||||
bindDn: v.bindDn,
|
||||
bindPasswordEnc: v.bindPassword
|
||||
? encrypt(v.bindPassword)
|
||||
: v.clearBindPassword || !same
|
||||
? null
|
||||
: (old?.bindPasswordEnc ?? null),
|
||||
baseDn: v.baseDn,
|
||||
userFilter: v.userFilter,
|
||||
idAttribute: v.idAttribute,
|
||||
emailAttribute: v.emailAttribute,
|
||||
nameAttribute: v.nameAttribute,
|
||||
adminFilter: v.adminFilter,
|
||||
};
|
||||
}
|
||||
|
||||
/** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
|
||||
async function oidcFromInput(input: OidcInput | null): Promise<OidcSettings | null> {
|
||||
if (!input) return null;
|
||||
try {
|
||||
assertIssuer(input.issuer);
|
||||
} catch (err) {
|
||||
throw badRequest((err as Error).message);
|
||||
}
|
||||
const old = await getSetting(db, 'auth.oidc');
|
||||
// As for the directory: the stored secret only goes to the provider it was typed for.
|
||||
const same = old?.issuer === input.issuer && old.clientId === input.clientId;
|
||||
if (!input.clientSecret && !input.clearClientSecret && !same && old?.clientSecretEnc)
|
||||
throw badRequest('The issuer or client ID changed: enter the client secret again');
|
||||
return {
|
||||
label: input.label,
|
||||
issuer: input.issuer,
|
||||
clientId: input.clientId,
|
||||
clientSecretEnc: input.clientSecret
|
||||
? encrypt(input.clientSecret)
|
||||
: input.clearClientSecret
|
||||
? null
|
||||
: same
|
||||
? (old.clientSecretEnc ?? null)
|
||||
: null,
|
||||
scopes: input.scopes,
|
||||
groupsClaim: input.groupsClaim,
|
||||
adminGroups: input.adminGroups,
|
||||
allowedGroups: input.allowedGroups,
|
||||
trustEmail: input.trustEmail,
|
||||
};
|
||||
}
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/settings', () => toDto(db));
|
||||
@@ -113,8 +229,37 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
secure: input.secure,
|
||||
username: input.username,
|
||||
from: input.from,
|
||||
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
|
||||
// Kept only for the same server and account: a new host doesn't get the old password.
|
||||
passwordEnc: input.password
|
||||
? encrypt(input.password)
|
||||
: old?.host === input.host && old.username === input.username
|
||||
? 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 if (key === 'auth.ldap') {
|
||||
values[key] = await ldapFromInput(raw);
|
||||
} else if (key === 'auth.oidc') {
|
||||
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
}
|
||||
await checkRefs(db, values);
|
||||
@@ -128,6 +273,21 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
const before = await getAllSettings(db);
|
||||
// 12 §1.1: mixed and oidc need a working OpenID Connect setup.
|
||||
if ('auth.mode' in values || 'auth.oidc' in values) {
|
||||
const mode = (values['auth.mode'] ?? before['auth.mode']) as AuthMode;
|
||||
const oidc = (
|
||||
'auth.oidc' in values ? values['auth.oidc'] : before['auth.oidc']
|
||||
) as OidcSettings | null;
|
||||
if (mode !== 'password') {
|
||||
if (!oidc) throw badRequest('Set up OpenID Connect first');
|
||||
try {
|
||||
await oidcConfig(oidc);
|
||||
} catch (err) {
|
||||
throw badRequest(`OpenID Connect doesn't work: ${oidcError(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
|
||||
const c = app.config;
|
||||
const [sip, tls] = [
|
||||
@@ -168,7 +328,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return toDto(db);
|
||||
});
|
||||
|
||||
app.post('/api/settings/smtp/test', async (req) => {
|
||||
app.post('/api/settings/smtp/test', { config: SLOW }, async (req) => {
|
||||
const { to } = z.object({ to: z.email() }).parse(req.body);
|
||||
try {
|
||||
await app.sendMail({
|
||||
@@ -183,7 +343,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
|
||||
app.post('/api/settings/emergency/test', async (req) => {
|
||||
app.post('/api/settings/emergency/test', { config: SLOW }, 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 },
|
||||
@@ -205,8 +365,46 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return { notified };
|
||||
});
|
||||
|
||||
/**
|
||||
* Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
|
||||
* is used once and never stored or logged.
|
||||
*/
|
||||
app.post(
|
||||
'/api/settings/auth/ldap/test',
|
||||
{ config: SLOW },
|
||||
async (req): Promise<LdapTestResult> => {
|
||||
const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
|
||||
testLogin: z.string().trim().max(254).optional(),
|
||||
testPassword: z.string().max(200).optional(),
|
||||
}).parse(req.body);
|
||||
const s = (await ldapFromInput(form))!;
|
||||
try {
|
||||
const r = await testLdap(s, testLogin || undefined, testPassword);
|
||||
if (!r.user) return { ok: true, matching: r.matching };
|
||||
const matches = await directoryUserFor(db, r.user);
|
||||
return {
|
||||
ok: true,
|
||||
matching: r.matching,
|
||||
user: { ...r.user, matches: matches?.email ?? null },
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
|
||||
app.post(
|
||||
'/api/settings/auth/oidc/test',
|
||||
{ config: SLOW },
|
||||
async (req): Promise<OidcTestResult> => {
|
||||
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
|
||||
return testOidc(s!, app.config.PUBLIC_HOSTNAME);
|
||||
},
|
||||
);
|
||||
|
||||
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
|
||||
app.post('/api/settings/network/detect-public-ip', async () => {
|
||||
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
|
||||
const ip = await detectPublicIp();
|
||||
if (ip) return { ip };
|
||||
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
|
||||
@@ -228,7 +426,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
/** Settings → AI → Test (spec 10): the values in the form, saved or not. */
|
||||
app.post('/api/ai/test', async (req): Promise<AiTestResult> => {
|
||||
app.post('/api/ai/test', { config: SLOW }, 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,
|
||||
@@ -284,6 +482,12 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
|
||||
link: '/admin/settings/backups',
|
||||
});
|
||||
if (s['system.directoryWarning'])
|
||||
items.push({
|
||||
code: 'directory_check',
|
||||
message: s['system.directoryWarning'],
|
||||
link: '/admin/settings/sign-in',
|
||||
});
|
||||
if (!s.smtp)
|
||||
items.push({
|
||||
code: 'smtp_missing',
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
SystemStatus,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getAllSettings } from '../services/settings.ts';
|
||||
import { systemEvents } from '../db/schema.ts';
|
||||
@@ -175,14 +175,18 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
|
||||
checks: await insideChecks(diag()),
|
||||
}));
|
||||
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
|
||||
const { mode } = z
|
||||
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
|
||||
.parse(req.body ?? {});
|
||||
if (!(await getAllSettings(app.db))['network.probeUrl'])
|
||||
throw badRequest('No network test probe set (Settings → Network)');
|
||||
return networkTest(diag(), mode);
|
||||
});
|
||||
app.post(
|
||||
'/api/system/diagnostics/network',
|
||||
{ config: SLOW },
|
||||
async (req): Promise<NetworkTestDto> => {
|
||||
const { mode } = z
|
||||
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
|
||||
.parse(req.body ?? {});
|
||||
if (!(await getAllSettings(app.db))['network.probeUrl'])
|
||||
throw badRequest('No network test probe set (Settings → Network)');
|
||||
return networkTest(diag(), mode);
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/system/generated-files', async () => ({
|
||||
files: await Promise.all(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
|
||||
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import {
|
||||
getDids,
|
||||
@@ -202,7 +202,7 @@ export async function trunkRoutes(app: FastifyInstance) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/trunks/:id/test', async (req): Promise<TrunkTestResult> => {
|
||||
app.post('/api/trunks/:id/test', { config: SLOW }, async (req): Promise<TrunkTestResult> => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const t = await getTrunk(db, id);
|
||||
if (!t.enabled) throw badRequest('Turn the trunk on first');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/users (04 §7.3). Admin only.
|
||||
import { and, asc, count, eq, ne } from 'drizzle-orm';
|
||||
import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
@@ -10,17 +10,30 @@ import {
|
||||
type UserDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { extensions, userIdentities, users } from '../db/schema.ts';
|
||||
import { HttpError, badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { hashPassword } from '../auth/passwords.ts';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import { randomAlnum } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { signInWays } from './auth.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
type IdentityRow = {
|
||||
userId: string;
|
||||
kind: 'oidc' | 'ldap';
|
||||
email: string | null;
|
||||
groups: string[];
|
||||
};
|
||||
|
||||
export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
|
||||
export const toUserDto = (
|
||||
u: UserRow,
|
||||
extension: UserDto['extension'] = null,
|
||||
ids: IdentityRow[] = [],
|
||||
from: RoleSources = { oidc: null, ldap: false },
|
||||
): UserDto => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
@@ -31,8 +44,39 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
extension,
|
||||
signIn: signInWays(u, ids),
|
||||
roleFrom: roleFrom(ids, from),
|
||||
});
|
||||
|
||||
/** The providers that set roles now (12 §1): adminGroups / adminFilter set and in use. */
|
||||
type RoleSources = { oidc: string | null; ldap: boolean };
|
||||
async function roleSources(db: Db): Promise<RoleSources> {
|
||||
const [oidc, ldap, mode] = await Promise.all([
|
||||
getSetting(db, 'auth.oidc'),
|
||||
getSetting(db, 'auth.ldap'),
|
||||
getSetting(db, 'auth.mode'),
|
||||
]);
|
||||
return {
|
||||
oidc: oidc && mode !== 'password' && oidc.adminGroups.length > 0 ? oidc.label : null,
|
||||
ldap: Boolean(ldap?.enabled && ldap.adminFilter),
|
||||
};
|
||||
}
|
||||
const roleFrom = (ids: { kind: string }[], from: RoleSources) =>
|
||||
(from.oidc && ids.some((i) => i.kind === 'oidc') && from.oidc) ||
|
||||
(from.ldap && ids.some((i) => i.kind === 'ldap') ? 'the directory' : null);
|
||||
|
||||
/** Connected accounts (12 §2), for one user or all of them. */
|
||||
const identitiesOf = (db: Db, userId?: string): Promise<IdentityRow[]> =>
|
||||
db
|
||||
.select({
|
||||
userId: userIdentities.userId,
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
groups: userIdentities.groups,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(userId ? eq(userIdentities.userId, userId) : undefined);
|
||||
|
||||
const extOf = async (db: Db, userId: string) => {
|
||||
const [e] = await db
|
||||
.select({ id: extensions.id, number: extensions.number })
|
||||
@@ -56,13 +100,27 @@ async function getUser(db: Db, id: string): Promise<UserRow> {
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Throws if removing `id` from the active admins would leave none. */
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
/**
|
||||
* Removing `id` from the active admins would leave none with a password: the way in when the
|
||||
* identity provider is down (12 §1).
|
||||
*/
|
||||
export async function isLastAdmin(db: Db, id: string): Promise<boolean> {
|
||||
const [r] = await db
|
||||
.select({ n: count() })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
|
||||
if (!r || r.n === 0) throw conflict('This is the last active admin');
|
||||
.where(
|
||||
and(
|
||||
eq(users.role, 'admin'),
|
||||
eq(users.isActive, true),
|
||||
isNotNull(users.passwordHash),
|
||||
ne(users.id, id),
|
||||
),
|
||||
);
|
||||
return !r || r.n === 0;
|
||||
}
|
||||
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
if (await isLastAdmin(db, id)) throw conflict('This is the last active admin with a password');
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
@@ -76,9 +134,16 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
.from(users)
|
||||
.leftJoin(extensions, eq(extensions.userId, users.id))
|
||||
.orderBy(asc(users.displayName), asc(users.id));
|
||||
const ids = await identitiesOf(db);
|
||||
const from = await roleSources(db);
|
||||
return {
|
||||
items: rows.map((r) =>
|
||||
toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null),
|
||||
toUserDto(
|
||||
r.u,
|
||||
r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
|
||||
ids.filter((i) => i.userId === r.u.id),
|
||||
from,
|
||||
),
|
||||
),
|
||||
nextCursor: null,
|
||||
};
|
||||
@@ -86,17 +151,27 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/users/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
return toUserDto(await getUser(db, id), await extOf(db, id));
|
||||
return toUserDto(
|
||||
await getUser(db, id),
|
||||
await extOf(db, id),
|
||||
await identitiesOf(db, id),
|
||||
await roleSources(db),
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/users', async (req, reply) => {
|
||||
const body = UserCreateSchema.parse(req.body);
|
||||
const password = temporaryPassword();
|
||||
// OpenID only (12 §3.3): no password; their first sign-in matches them by verified email.
|
||||
const password = (await getSetting(db, 'auth.mode')) === 'oidc' ? null : temporaryPassword();
|
||||
let created: UserRow;
|
||||
try {
|
||||
[created] = (await db
|
||||
.insert(users)
|
||||
.values({ ...body, passwordHash: await hashPassword(password), mustChangePassword: true })
|
||||
.values({
|
||||
...body,
|
||||
passwordHash: password && (await hashPassword(password)),
|
||||
mustChangePassword: password !== null,
|
||||
})
|
||||
.returning()) as [UserRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw conflict('A user with this email already exists');
|
||||
@@ -120,6 +195,12 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
const body = UserUpdateSchema.parse(req.body);
|
||||
if (Object.keys(body).length === 0) throw badRequest('Nothing to update');
|
||||
const before = await getUser(db, id);
|
||||
const ids = await identitiesOf(db, id);
|
||||
const from = await roleSources(db);
|
||||
// 12 §1: a provider that sets the role would undo this at the next sign-in or check.
|
||||
const managed = roleFrom(ids, from);
|
||||
if (body.role !== undefined && body.role !== before.role && managed)
|
||||
throw new HttpError(409, 'role_managed', `The role comes from ${managed}`);
|
||||
const losesAdmin =
|
||||
before.role === 'admin' &&
|
||||
before.isActive &&
|
||||
@@ -150,7 +231,7 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
|
||||
},
|
||||
});
|
||||
return toUserDto(after, await extOf(db, id));
|
||||
return toUserDto(after, await extOf(db, id), ids, from);
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', async (req) => {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The call report email (05 §4): each user's calls of the last hour, day or week, with each
|
||||
// recorded call's summary if they asked for it. No calls, no email.
|
||||
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber, type CallReport } from '@selfphoneng/shared';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
|
||||
import type { SendMail } from './mail.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
/** Calls are written a few seconds after hang-up and summarized a minute or two after. */
|
||||
const LAG_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* The last whole period that ended by `now`, in the time zone `tz`: e.g. daily at 08:00 is
|
||||
* yesterday 08:00 to today 08:00. Local-time arithmetic in PostgreSQL, so DST days are 23 or 25 h.
|
||||
*/
|
||||
export async function lastPeriod(
|
||||
db: Db,
|
||||
tz: string,
|
||||
s: { callReport: Exclude<CallReport, 'off'>; callReportTime: string; callReportWeekday: number },
|
||||
now: Date,
|
||||
): Promise<{ from: Date; to: Date }> {
|
||||
const r = await db.execute<{ from: Date; to: Date }>(sql`
|
||||
with l as (select (${now.toISOString()}::timestamptz at time zone ${tz}::text) as t),
|
||||
s as (
|
||||
select t, case ${s.callReport}::text
|
||||
when 'hourly' then date_trunc('hour', t)
|
||||
when 'daily' then t::date + ${s.callReportTime}::time
|
||||
else t::date - ((extract(dow from t)::int - ${s.callReportWeekday}::int + 7) % 7)
|
||||
+ ${s.callReportTime}::time
|
||||
end as at,
|
||||
case ${s.callReport}::text
|
||||
when 'hourly' then interval '1 hour'
|
||||
when 'daily' then interval '1 day'
|
||||
else interval '7 days'
|
||||
end as p
|
||||
from l),
|
||||
e as (select case when at > t then at - p else at end as at, p from s)
|
||||
select (at - p) at time zone ${tz}::text as "from", at at time zone ${tz}::text as "to" from e`);
|
||||
const row = r.rows[0]!;
|
||||
return { from: new Date(row.from), to: new Date(row.to) };
|
||||
}
|
||||
|
||||
const duration = (s: number) =>
|
||||
s >= 3600
|
||||
? `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
/** "102" stays as is; outside numbers get their usual format. */
|
||||
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'unknown');
|
||||
|
||||
export type ReportCall = {
|
||||
startedAt: Date;
|
||||
out: boolean;
|
||||
status: string;
|
||||
fromNumber: string | null;
|
||||
fromName: string | null;
|
||||
/** The other side's name in my contacts or the company's (11 §1). */
|
||||
contactName: string | null;
|
||||
toNumber: string | null;
|
||||
talkSeconds: number;
|
||||
answeredBy: string | null;
|
||||
recorded: boolean;
|
||||
summary: string | null;
|
||||
summaryPending: boolean;
|
||||
};
|
||||
|
||||
/** The email's text: one block per call, oldest first. */
|
||||
export function reportText(
|
||||
tz: string,
|
||||
period: { from: Date; to: Date },
|
||||
list: ReportCall[],
|
||||
withSummaries: boolean,
|
||||
): string {
|
||||
const when = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
const lines = [
|
||||
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
|
||||
];
|
||||
for (const c of list) {
|
||||
const name = c.contactName ?? (c.out ? null : c.fromName);
|
||||
const who = c.out
|
||||
? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
|
||||
: `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
|
||||
const facts = [
|
||||
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
|
||||
duration(c.talkSeconds),
|
||||
...(c.recorded ? ['recorded'] : []),
|
||||
];
|
||||
lines.push('', `${when.format(c.startedAt)} ${who}`, ` ${facts.join(' · ')}`);
|
||||
if (withSummaries && c.summary)
|
||||
lines.push(
|
||||
...c.summary
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`),
|
||||
);
|
||||
else if (withSummaries && c.summaryPending)
|
||||
lines.push(' Summary not ready yet: see Recordings in the web portal.');
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
|
||||
/** One run: every user whose period ended and wasn't reported on yet. */
|
||||
export async function sendCallReports(d: {
|
||||
db: Db;
|
||||
send: SendMail;
|
||||
log: { error(o: object, m: string): void };
|
||||
now: Date;
|
||||
}): Promise<number> {
|
||||
const tz = await getSetting(d.db, 'general.timezone');
|
||||
const asOf = new Date(d.now.getTime() - LAG_MS);
|
||||
const due = await d.db
|
||||
.select({ e: extensions, email: users.email })
|
||||
.from(extensions)
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.where(and(ne(extensions.callReport, 'off'), eq(users.isActive, true)));
|
||||
let sent = 0;
|
||||
for (const { e, email } of due) {
|
||||
if (e.callReport === 'off') continue;
|
||||
const period = await lastPeriod(d.db, tz, { ...e, callReport: e.callReport }, asOf);
|
||||
if (e.callReportSentAt && e.callReportSentAt >= period.to) continue;
|
||||
// ponytail: by hang-up time, and only the last period: one that passed while the server
|
||||
// was down isn't caught up.
|
||||
const rows = await d.db
|
||||
.select({
|
||||
c: calls,
|
||||
answeredBy: answerer.name,
|
||||
answeredById: answerer.id,
|
||||
summary: recordings.summary,
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
|
||||
})
|
||||
.from(calls)
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
|
||||
.where(
|
||||
and(
|
||||
gte(calls.endedAt, period.from),
|
||||
lt(calls.endedAt, period.to),
|
||||
inArray(
|
||||
calls.id,
|
||||
d.db
|
||||
.select({ id: callExtensions.callId })
|
||||
.from(callExtensions)
|
||||
.where(eq(callExtensions.extensionId, e.id)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(calls.startedAt));
|
||||
// Marked first: a failing mail server is logged once, not retried every minute.
|
||||
await d.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: period.to })
|
||||
.where(eq(extensions.id, e.id));
|
||||
if (!rows.length) continue;
|
||||
const names = await contactNames(
|
||||
d.db,
|
||||
e.userId,
|
||||
rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
|
||||
);
|
||||
const list = rows.map((r): ReportCall => ({
|
||||
startedAt: r.c.startedAt,
|
||||
out: r.out,
|
||||
status: r.c.status,
|
||||
fromNumber: r.c.fromNumber,
|
||||
fromName: r.c.fromName,
|
||||
contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
|
||||
toNumber: r.c.toNumber,
|
||||
talkSeconds: r.c.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
||||
recorded: r.c.recordingId !== null,
|
||||
summary: r.summary,
|
||||
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
|
||||
}));
|
||||
const withSummaries =
|
||||
e.callReportSummaries &&
|
||||
(await getSetting(d.db, 'ai.callTranscripts')) &&
|
||||
(await getSetting(d.db, 'ai.callSummaries'));
|
||||
try {
|
||||
await d.send({
|
||||
to: [email],
|
||||
subject: `Your calls (${e.number}): ${list.length} call${list.length === 1 ? '' : 's'}`,
|
||||
text: reportText(tz, period, list, withSummaries),
|
||||
});
|
||||
sent++;
|
||||
} catch (err) {
|
||||
d.log.error({ err, extension: e.number }, 'call report not sent');
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { claimValues, decide, verifiedEmail } from './claims.ts';
|
||||
|
||||
describe('OpenID claims (12 §3.4)', () => {
|
||||
const keycloak = {
|
||||
groups: ['/pbx-admins', 'pbx-users'],
|
||||
realm_access: { roles: ['offline_access', 'pbx-admin'] },
|
||||
resource_access: { selfphoneng: { roles: ['operator'] } },
|
||||
'urn:zitadel:iam:org:project:roles': { 'pbx-admin': { '123': 'example.com' } },
|
||||
hd: 'example.com',
|
||||
};
|
||||
|
||||
it('reads dotted paths, whole keys first', () => {
|
||||
expect(claimValues(keycloak, 'groups')).toEqual(['/pbx-admins', 'pbx-users']);
|
||||
expect(claimValues(keycloak, 'realm_access.roles')).toContain('pbx-admin');
|
||||
expect(claimValues(keycloak, 'resource_access.selfphoneng.roles')).toEqual(['operator']);
|
||||
expect(claimValues(keycloak, 'urn:zitadel:iam:org:project:roles')).toEqual(['pbx-admin']);
|
||||
expect(claimValues(keycloak, 'hd')).toEqual(['example.com']);
|
||||
expect(claimValues(keycloak, 'missing.path')).toEqual([]);
|
||||
expect(claimValues({ 'a.b': ['x'], a: { b: ['y'] } }, 'a.b')).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('decides allowed and role, comparing names exactly', () => {
|
||||
const rules = { groupsClaim: 'groups', adminGroups: [], allowedGroups: [] };
|
||||
expect(decide(rules, keycloak)).toMatchObject({ allowed: true, admin: null });
|
||||
expect(decide({ ...rules, adminGroups: ['pbx-admins'] }, keycloak).admin).toBe(false);
|
||||
expect(decide({ ...rules, adminGroups: ['/pbx-admins'] }, keycloak).admin).toBe(true);
|
||||
expect(decide({ ...rules, allowedGroups: ['sales'] }, keycloak).allowed).toBe(false);
|
||||
expect(decide({ ...rules, allowedGroups: ['sales', 'pbx-users'] }, keycloak).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes an email only when verified, or trusted', () => {
|
||||
expect(verifiedEmail({ email: 'a@x.com', email_verified: true }, false)).toBe('a@x.com');
|
||||
expect(verifiedEmail({ email: 'a@x.com', email_verified: 'true' }, false)).toBeNull();
|
||||
expect(verifiedEmail({ email: 'a@x.com' }, false)).toBeNull();
|
||||
expect(verifiedEmail({ email: 'a@x.com' }, true)).toBe('a@x.com');
|
||||
expect(verifiedEmail({}, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// OpenID claims → groups, allowed, role (12 §3.4). Pure: no I/O.
|
||||
|
||||
type Claims = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The value at a dotted path. The whole remaining path is tried as one key first, so claim
|
||||
* names with dots or colons (Zitadel's "urn:zitadel:iam:org:project:roles") work, and
|
||||
* "resource_access.selfphoneng.roles" walks down.
|
||||
*/
|
||||
export function claimAt(claims: unknown, path: string): unknown {
|
||||
if (!claims || typeof claims !== 'object') return undefined;
|
||||
const o = claims as Claims;
|
||||
if (path in o) return o[path];
|
||||
const dot = path.indexOf('.');
|
||||
return dot < 0 ? undefined : claimAt(o[path.slice(0, dot)], path.slice(dot + 1));
|
||||
}
|
||||
|
||||
/** A string → [it]; an array → its strings; an object → its keys (Zitadel's roles). */
|
||||
export function claimValues(claims: unknown, path: string): string[] {
|
||||
const v = claimAt(claims, path);
|
||||
if (typeof v === 'string') return [v];
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
|
||||
if (v && typeof v === 'object') return Object.keys(v);
|
||||
return [];
|
||||
}
|
||||
|
||||
export type OidcRules = { groupsClaim: string; adminGroups: string[]; allowedGroups: string[] };
|
||||
|
||||
export type ClaimsDecision = {
|
||||
groups: string[];
|
||||
allowed: boolean;
|
||||
/** null: the role is managed in SelfPhoneNG (no adminGroups). */
|
||||
admin: boolean | null;
|
||||
};
|
||||
|
||||
export function decide(rules: OidcRules, claims: Claims): ClaimsDecision {
|
||||
const groups = claimValues(claims, rules.groupsClaim);
|
||||
const any = (list: string[]) => list.some((g) => groups.includes(g));
|
||||
return {
|
||||
groups,
|
||||
allowed: rules.allowedGroups.length === 0 || any(rules.allowedGroups),
|
||||
admin: rules.adminGroups.length === 0 ? null : any(rules.adminGroups),
|
||||
};
|
||||
}
|
||||
|
||||
/** The email, when the provider says it's verified (or the admin trusts its emails, 12 §3.3). */
|
||||
export function verifiedEmail(claims: Claims, trustEmail: boolean): string | null {
|
||||
const email = typeof claims.email === 'string' ? claims.email.trim() : '';
|
||||
if (!email) return null;
|
||||
return claims.email_verified === true || trustEmail ? email : null;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
|
||||
// email. Never a new user (owner, 2026-10-25).
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { userIdentities, users } from '../db/schema.ts';
|
||||
import { isLastAdmin } from '../routes/users.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting, setSetting } from '../services/settings.ts';
|
||||
import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
|
||||
import { applyRole } from './role.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
|
||||
/** Who this entry is here, without changing anything (Settings → Test says it too). */
|
||||
export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserRow | null> {
|
||||
const [byId] = await db
|
||||
.select({ u: users })
|
||||
.from(userIdentities)
|
||||
.innerJoin(users, eq(users.id, userIdentities.userId))
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||
if (byId) return byId.u;
|
||||
if (!e.email) return null;
|
||||
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
|
||||
return byEmail && byEmailOk(byEmail) ? byEmail : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a directory email may connect to on its own (12 §4.3 step 2): not an admin, and nobody
|
||||
* who chose their own password (none, or only the temporary one from Users). Anyone else
|
||||
* connects on My settings with both passwords: a directory that lets people edit their own
|
||||
* `mail` must not hand out the admin's account.
|
||||
*/
|
||||
const byEmailOk = (u: UserRow) =>
|
||||
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
|
||||
|
||||
/** Connect a directory account the person proved (My settings, 12 §4.3). */
|
||||
export async function connectDirectory(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
userId: string,
|
||||
e: DirectoryEntry,
|
||||
ip: string,
|
||||
): Promise<'connected' | 'taken'> {
|
||||
const [owner] = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||
if (owner && owner.userId !== userId) return 'taken';
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
|
||||
await tx
|
||||
.insert(userIdentities)
|
||||
.values({ userId, kind: 'ldap', subject: e.id, email: e.email, lastUsedAt: new Date() });
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: ip,
|
||||
action: 'ldap_connected',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Connected directory account ${e.dn}`,
|
||||
});
|
||||
await applyRole(db, log, userId, e.admin, 'the directory', ip);
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
/**
|
||||
* The user this directory sign-in is for, connected now if it was matched by email (the
|
||||
* directory is the company's own and the person just proved its password), with the role from
|
||||
* adminFilter. Null: nobody here has this directory account or email.
|
||||
*/
|
||||
export async function directorySignIn(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
e: DirectoryEntry,
|
||||
ip: string,
|
||||
): Promise<UserRow | null> {
|
||||
const u = await directoryUserFor(db, e);
|
||||
if (!u) return null;
|
||||
const [mine] = await db
|
||||
.select({ subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
if (!mine) {
|
||||
await db.insert(userIdentities).values({
|
||||
userId: u.id,
|
||||
kind: 'ldap',
|
||||
subject: e.id,
|
||||
email: e.email,
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: u.id,
|
||||
actorIp: ip,
|
||||
action: 'ldap_connected',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${u.email} connected directory account ${e.dn}`,
|
||||
});
|
||||
} else if (mine.subject !== e.id) {
|
||||
// Their email matches, but another directory account is already theirs: not this one.
|
||||
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
|
||||
return null;
|
||||
} else
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email: e.email, lastUsedAt: new Date() })
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
await applyRole(db, log, u.id, e.admin, 'the directory', ip);
|
||||
const [after] = await db.select().from(users).where(eq(users.id, u.id));
|
||||
return after!;
|
||||
}
|
||||
|
||||
/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
|
||||
export const directoryWarning = (db: Db, message: string | null) =>
|
||||
setSetting(db, 'system.directoryWarning', message);
|
||||
|
||||
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
|
||||
|
||||
/**
|
||||
* One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
|
||||
* account still in it gets its email and role; one who isn't is disabled. Never re-enables.
|
||||
* `lookup` is for tests.
|
||||
*/
|
||||
export async function checkDirectory(d: {
|
||||
db: Db;
|
||||
log: Log;
|
||||
lookup?: (s: LdapSettings, ids: string[]) => Promise<Map<string, DirectoryEntry>>;
|
||||
}): Promise<void> {
|
||||
const { db, log } = d;
|
||||
const s = await getSetting(db, 'auth.ldap');
|
||||
const rows = s?.enabled
|
||||
? await db
|
||||
.select({ u: users, subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.innerJoin(users, eq(users.id, userIdentities.userId))
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(users.isActive, true)))
|
||||
: [];
|
||||
if (!s || !rows.length) return directoryWarning(db, null);
|
||||
|
||||
let found: Map<string, DirectoryEntry>;
|
||||
try {
|
||||
found = await (d.lookup ?? lookupIds)(
|
||||
s,
|
||||
rows.map((r) => r.subject),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn?.({ err: (err as Error).message }, 'directory check skipped');
|
||||
return directoryWarning(
|
||||
db,
|
||||
`The directory check couldn't reach the directory (${(err as Error).message}), so leavers aren't being disabled.`,
|
||||
);
|
||||
}
|
||||
const gone = rows.filter((r) => !found.has(r.subject));
|
||||
// A broken filter or base DN is likelier than a mass layoff.
|
||||
if (gone.length >= 3 && gone.length * 2 > rows.length) {
|
||||
log.warn?.({ gone: gone.length, of: rows.length }, 'directory check would disable most users');
|
||||
return directoryWarning(
|
||||
db,
|
||||
`The directory check would disable ${gone.length} of ${rows.length} people, so it disabled nobody. Check the base DN and user filter.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { u, subject } of rows) {
|
||||
const e = found.get(subject);
|
||||
if (e) {
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email: e.email })
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
await applyRole(db, log, u.id, e.admin, 'the directory', null);
|
||||
continue;
|
||||
}
|
||||
if (u.role === 'admin' && u.passwordHash && (await isLastAdmin(db, u.id))) {
|
||||
log.warn?.(
|
||||
{ userId: u.id },
|
||||
'kept the last admin with a password, though the directory lost them',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
|
||||
await deleteUserSessions(db, u.id);
|
||||
await audit(db, {
|
||||
action: 'directory_disabled',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${u.email} is no longer in the directory`,
|
||||
diff: { before: { isActive: true }, after: { isActive: false } },
|
||||
});
|
||||
}
|
||||
await directoryWarning(db, null);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { BerWriter, Client, UnwillingToPerformError } from 'ldapts';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
anyLogin,
|
||||
assertLdap,
|
||||
byIdFilter,
|
||||
DirectoryUnavailable,
|
||||
ldapAuthenticate,
|
||||
withLogin,
|
||||
type LdapSettings,
|
||||
} from './ldap.ts';
|
||||
|
||||
describe('LDAP (12 §4)', () => {
|
||||
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
|
||||
|
||||
it('escapes the login, so it can only ever be a value (RFC 4515)', () => {
|
||||
expect(withLogin(f, 'ann')).toBe('(&(objectClass=person)(|(uid=ann)(mail=ann)))');
|
||||
expect(withLogin(f, '*)(uid=*))(|(uid=*')).toBe(
|
||||
'(&(objectClass=person)(|(uid=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)(mail=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)))',
|
||||
);
|
||||
expect(withLogin('(uid={login})', 'a\\b\u0000')).toBe('(uid=a\\5cb\\00)');
|
||||
});
|
||||
|
||||
it('{login} → * keeps the other conditions, for the leaver check', () => {
|
||||
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
|
||||
});
|
||||
|
||||
it('finds a leaver by id, keeping the other conditions of the filter (§4.5)', () => {
|
||||
expect(byIdFilter({ idAttribute: 'entryUUID', userFilter: f }, 'a*b').toString()).toBe(
|
||||
'(&(entryUUID=a\\2ab)(&(objectClass=person)(|(uid=*)(mail=*))))',
|
||||
);
|
||||
// AD: objectGUID is stored as hex and searched as its bytes (§4.4).
|
||||
const guid = byIdFilter({ idAttribute: 'objectGUID', userFilter: '(cn={login})' }, '00ff2a');
|
||||
expect(guid.toString()).toBe('(&(objectGUID=\\00\\ff\\2a)(cn=*))');
|
||||
// …and goes out as those three bytes, not U+00FF in UTF-8.
|
||||
const ber = new BerWriter();
|
||||
guid.write(ber);
|
||||
expect(ber.buffer.includes(Buffer.from([0x04, 0x03, 0x00, 0xff, 0x2a]))).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends a password in clear text to another host', () => {
|
||||
const ok = { startTls: false, userFilter: f, adminFilter: null };
|
||||
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
|
||||
expect(() =>
|
||||
assertLdap({ ...ok, url: 'ldap://dc1.example.com', startTls: true }),
|
||||
).not.toThrow();
|
||||
expect(() => assertLdap({ ...ok, url: 'ldaps://dc1.example.com' })).not.toThrow();
|
||||
expect(() => assertLdap({ ...ok, url: 'ldap://127.0.0.1:3890' })).not.toThrow();
|
||||
expect(() => assertLdap({ ...ok, url: 'ldaps://x', startTls: true })).toThrow(/StartTLS/);
|
||||
});
|
||||
|
||||
it('refuses filters that do not parse', () => {
|
||||
const base = { url: 'ldaps://x', startTls: false, userFilter: f, adminFilter: null };
|
||||
expect(() => assertLdap({ ...base, userFilter: '(uid={login}' })).toThrow(/User filter/);
|
||||
expect(() => assertLdap({ ...base, adminFilter: 'memberOf=x)' })).toThrow(/Admin filter/);
|
||||
});
|
||||
|
||||
it('an empty password fails before anything is sent (no "unauthenticated bind")', async () => {
|
||||
// A directory that doesn't exist: any attempt to connect would throw DirectoryUnavailable.
|
||||
const s = { url: 'ldaps://192.0.2.1', startTls: false, userFilter: f } as LdapSettings;
|
||||
expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a directory that answers no (12 §4.2)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const s = {
|
||||
url: 'ldaps://dir.example.com',
|
||||
startTls: false,
|
||||
bindDn: null,
|
||||
baseDn: 'dc=example,dc=com',
|
||||
userFilter: '(uid={login})',
|
||||
idAttribute: 'entryUUID',
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: null,
|
||||
} as LdapSettings;
|
||||
|
||||
it('a locked account (389 DS, FreeIPA: not code 49) is a refused password, not "unavailable"', async () => {
|
||||
vi.spyOn(Client.prototype, 'search').mockResolvedValue({
|
||||
searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
|
||||
searchReferences: [],
|
||||
});
|
||||
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, 'bind').mockRejectedValue(
|
||||
new UnwillingToPerformError('Account locked'),
|
||||
);
|
||||
expect(await ldapAuthenticate(s, 'ann', 'right-but-locked')).toBeNull();
|
||||
});
|
||||
|
||||
it('no answer at all is "unavailable"', async () => {
|
||||
vi.spyOn(Client.prototype, 'search').mockResolvedValue({
|
||||
searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
|
||||
searchReferences: [],
|
||||
});
|
||||
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, 'bind').mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
await expect(ldapAuthenticate(s, 'ann', 'pw')).rejects.toThrow(DirectoryUnavailable);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
|
||||
// bind as them with their password, read whether the admin filter matches them.
|
||||
import {
|
||||
AndFilter,
|
||||
Client,
|
||||
EqualityFilter,
|
||||
FilterParser,
|
||||
InvalidCredentialsError,
|
||||
NoSuchObjectError,
|
||||
ResultCodeError,
|
||||
SizeLimitExceededError,
|
||||
escapeFilter,
|
||||
type Entry,
|
||||
} from 'ldapts';
|
||||
import type { SettingValue } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
|
||||
export type LdapSettings = NonNullable<SettingValue<'auth.ldap'>>;
|
||||
|
||||
/** The directory can't be asked (down, TLS, a refused service account): not a wrong password. */
|
||||
export class DirectoryUnavailable extends Error {}
|
||||
|
||||
const LOCAL = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
|
||||
|
||||
/** 12 §4.1: no password in clear text over the network, and filters that parse. */
|
||||
export function assertLdap(s: {
|
||||
url: string;
|
||||
startTls: boolean;
|
||||
userFilter: string;
|
||||
adminFilter: string | null;
|
||||
}): void {
|
||||
const u = new URL(s.url);
|
||||
if (u.protocol === 'ldaps:' && s.startTls) throw new Error('StartTLS is for ldap:// addresses');
|
||||
if (u.protocol === 'ldap:' && !s.startTls && !LOCAL.has(u.hostname))
|
||||
throw new Error('ldap:// sends passwords in clear text: use ldaps://, or turn on StartTLS');
|
||||
for (const [name, f] of [
|
||||
['User filter', s.userFilter],
|
||||
['Admin filter', s.adminFilter],
|
||||
] as const)
|
||||
try {
|
||||
if (f) FilterParser.parseString(f.replaceAll('{login}', 'x'));
|
||||
} catch (err) {
|
||||
throw new Error(`${name}: ${(err as Error).message}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {login} replaced by the RFC 4515-escaped value (ldapts escapeFilter). */
|
||||
export function withLogin(filter: string, login: string): string {
|
||||
const parts = filter.split('{login}');
|
||||
const strings = Object.assign([...parts], { raw: parts }) as unknown as TemplateStringsArray;
|
||||
return escapeFilter(strings, ...parts.slice(1).map(() => login));
|
||||
}
|
||||
|
||||
/** {login} → * : the filter's other conditions (group, disabled bit) without the login (§4.5). */
|
||||
export const anyLogin = (filter: string) => filter.replaceAll('{login}', '*');
|
||||
|
||||
const first = (v: Entry[string] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
/** One attribute as text: binary ids (AD's objectGUID) as lowercase hex (12 §4.4). */
|
||||
const attr = (e: Entry, name: string): string | null => {
|
||||
const v = first(e[name]);
|
||||
if (v === undefined) return null;
|
||||
return (Buffer.isBuffer(v) ? v.toString('hex') : String(v)) || null;
|
||||
};
|
||||
|
||||
const newClient = (s: LdapSettings) =>
|
||||
new Client({ url: s.url, timeout: 5000, connectTimeout: 5000 });
|
||||
|
||||
async function startTls(c: Client, s: LdapSettings) {
|
||||
if (s.startTls) await c.startTLS({ servername: new URL(s.url).hostname });
|
||||
}
|
||||
|
||||
/** A connection bound as the service account (or anonymous). */
|
||||
async function connect(s: LdapSettings): Promise<Client> {
|
||||
const c = newClient(s);
|
||||
try {
|
||||
await startTls(c, s);
|
||||
if (s.bindDn) {
|
||||
// A DN with an empty password is an anonymous "unauthenticated bind" (RFC 4513 §5.1.2).
|
||||
if (!s.bindPasswordEnc) throw new Error('The service account has no password');
|
||||
await c.bind(s.bindDn, decrypt(s.bindPasswordEnc));
|
||||
}
|
||||
return c;
|
||||
} catch (err) {
|
||||
await c.unbind().catch(() => {});
|
||||
throw new DirectoryUnavailable(
|
||||
err instanceof InvalidCredentialsError
|
||||
? 'The directory refused the service account'
|
||||
: (err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = (s: LdapSettings) => [s.idAttribute, s.emailAttribute, s.nameAttribute, 'cn'];
|
||||
const buffers = (s: LdapSettings) => (s.idAttribute === 'objectGUID' ? ['objectGUID'] : []);
|
||||
|
||||
/** The entries `login` matches (at most 2: more than one is as bad as none). */
|
||||
async function findByLogin(c: Client, s: LdapSettings, login: string): Promise<Entry[]> {
|
||||
try {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: withLogin(s.userFilter, login),
|
||||
attributes: attributes(s),
|
||||
explicitBufferAttributes: buffers(s),
|
||||
sizeLimit: 2,
|
||||
});
|
||||
return r.searchEntries;
|
||||
} catch (err) {
|
||||
if (err instanceof SizeLimitExceededError) return [{ dn: '' }, { dn: '' }];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type DirectoryEntry = {
|
||||
dn: string;
|
||||
/** The stable id attribute's value (12 §4.4). */
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
/** adminFilter's answer; null when there is none (the role is managed here). */
|
||||
admin: boolean | null;
|
||||
};
|
||||
|
||||
async function toEntry(c: Client, s: LdapSettings, e: Entry): Promise<DirectoryEntry> {
|
||||
const id = attr(e, s.idAttribute);
|
||||
if (!id) throw new DirectoryUnavailable(`${e.dn} has no ${s.idAttribute}`);
|
||||
let admin: boolean | null = null;
|
||||
if (s.adminFilter)
|
||||
try {
|
||||
const r = await c.search(e.dn, { scope: 'base', filter: s.adminFilter, attributes: ['1.1'] });
|
||||
admin = r.searchEntries.length > 0;
|
||||
} catch (err) {
|
||||
// lldap answers "no such object" when the entry doesn't match a base search's filter.
|
||||
if (!(err instanceof NoSuchObjectError)) throw err;
|
||||
admin = false;
|
||||
}
|
||||
return {
|
||||
dn: e.dn,
|
||||
id,
|
||||
email: attr(e, s.emailAttribute),
|
||||
name: attr(e, s.nameAttribute) ?? attr(e, 'cn'),
|
||||
admin,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is this the password of that entry? Its own connection, so the service one stays bound. */
|
||||
async function passwordWorks(s: LdapSettings, dn: string, password: string): Promise<boolean> {
|
||||
const u = newClient(s);
|
||||
try {
|
||||
await startTls(u, s);
|
||||
await u.bind(dn, password);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// The server answered (wrong password, locked, expired: 389 DS and FreeIPA say 19 or 53
|
||||
// for a locked account): a refused password. Only no answer at all is "unavailable", so
|
||||
// locking someone's account can't turn their sign-in into a 503.
|
||||
if (err instanceof ResultCodeError) return false;
|
||||
throw new DirectoryUnavailable((err as Error).message);
|
||||
} finally {
|
||||
await u.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const unavailable = (err: unknown) =>
|
||||
err instanceof DirectoryUnavailable ? err : new DirectoryUnavailable((err as Error).message);
|
||||
|
||||
/**
|
||||
* 12 §4.2 step 3: the directory entry for this login and password, or null when they don't
|
||||
* match exactly one entry with that password. Throws DirectoryUnavailable otherwise.
|
||||
*/
|
||||
export async function ldapAuthenticate(
|
||||
s: LdapSettings,
|
||||
login: string,
|
||||
password: string,
|
||||
): Promise<DirectoryEntry | null> {
|
||||
// Never bind with an empty password: many servers take it as anonymous success.
|
||||
if (!password) return null;
|
||||
const c = await connect(s);
|
||||
try {
|
||||
const found = await findByLogin(c, s, login);
|
||||
if (found.length !== 1) return null;
|
||||
if (!(await passwordWorks(s, found[0]!.dn, password))) return null;
|
||||
return await toEntry(c, s, found[0]!);
|
||||
} catch (err) {
|
||||
throw unavailable(err);
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** `(&(<id attribute>=<id>)<userFilter with {login} → *>)`: still there, and still let in (§4.5). */
|
||||
export function byIdFilter(s: Pick<LdapSettings, 'idAttribute' | 'userFilter'>, id: string) {
|
||||
// An object, not a string: ldapts parses "\ff" in a string filter to U+00FF and sends it as
|
||||
// UTF-8 (two bytes), so a binary objectGUID would never match. A Buffer goes out as is.
|
||||
const value = s.idAttribute === 'objectGUID' ? Buffer.from(id, 'hex') : id;
|
||||
return new AndFilter({
|
||||
filters: [
|
||||
new EqualityFilter({ attribute: s.idAttribute, value }),
|
||||
FilterParser.parseString(anyLogin(s.userFilter)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaver check (12 §4.5): each id → its entry, when the directory still has it and the user
|
||||
* filter still lets it in. One connection for all. Throws DirectoryUnavailable on any failure,
|
||||
* so a half-done run never reads as "gone".
|
||||
*/
|
||||
export async function lookupIds(
|
||||
s: LdapSettings,
|
||||
ids: string[],
|
||||
): Promise<Map<string, DirectoryEntry>> {
|
||||
const c = await connect(s);
|
||||
try {
|
||||
const found = new Map<string, DirectoryEntry>();
|
||||
for (const id of ids) {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: byIdFilter(s, id),
|
||||
attributes: attributes(s),
|
||||
explicitBufferAttributes: buffers(s),
|
||||
sizeLimit: 2,
|
||||
});
|
||||
if (r.searchEntries.length === 1) found.set(id, await toEntry(c, s, r.searchEntries[0]!));
|
||||
}
|
||||
return found;
|
||||
} catch (err) {
|
||||
throw unavailable(err);
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
|
||||
export async function testLdap(
|
||||
s: LdapSettings,
|
||||
login?: string,
|
||||
password?: string,
|
||||
): Promise<{ matching: number; user?: DirectoryEntry }> {
|
||||
const c = await connect(s);
|
||||
try {
|
||||
let matching: number;
|
||||
try {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: anyLogin(s.userFilter),
|
||||
attributes: ['1.1'],
|
||||
sizeLimit: 1000,
|
||||
});
|
||||
matching = r.searchEntries.length;
|
||||
} catch (err) {
|
||||
if (!(err instanceof SizeLimitExceededError)) throw err;
|
||||
matching = 1000;
|
||||
}
|
||||
if (!login) return { matching };
|
||||
const found = await findByLogin(c, s, login);
|
||||
if (found.length === 0) throw new Error(`No entry under ${s.baseDn} matches "${login}"`);
|
||||
if (found.length > 1) throw new Error(`More than one entry matches "${login}"`);
|
||||
if (!password || !(await passwordWorks(s, found[0]!.dn, password)))
|
||||
throw new Error(`The directory refused the password for ${found[0]!.dn}`);
|
||||
return { matching, user: await toEntry(c, s, found[0]!) };
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// OpenID Connect relying party (12 §3) on openid-client: discovery, PKCE + state + nonce, the
|
||||
// code grant, userinfo, test, signing out at the provider. No protocol code of our own.
|
||||
import * as oidc from 'openid-client';
|
||||
import type { OidcTestResult, SettingValue } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
|
||||
export type OidcSettings = NonNullable<SettingValue<'auth.oidc'>>;
|
||||
|
||||
/** http:// only for the fake provider in tests; a real provider is always https (12 §3.1). */
|
||||
const insecureOk = (u: URL) =>
|
||||
process.env.NODE_ENV === 'test' && ['127.0.0.1', 'localhost'].includes(u.hostname);
|
||||
|
||||
export function assertIssuer(issuer: string): void {
|
||||
const u = new URL(issuer);
|
||||
if (u.protocol !== 'https:' && !insecureOk(u)) throw new Error('The issuer must be https://');
|
||||
}
|
||||
|
||||
export const redirectUri = (host: string) => `https://${host}/api/auth/oidc/callback`;
|
||||
export const postLogoutUri = (host: string) => `https://${host}/login`;
|
||||
|
||||
let cached: { key: string; config: oidc.Configuration } | null = null;
|
||||
|
||||
/**
|
||||
* Discovers the provider once per settings value (a change or a failure means the next call
|
||||
* discovers again) and picks client_secret_basic, else _post, else none for a public client.
|
||||
*/
|
||||
export async function oidcConfig(s: OidcSettings): Promise<oidc.Configuration> {
|
||||
const key = JSON.stringify(s);
|
||||
if (cached?.key === key) return cached.config;
|
||||
const issuer = new URL(s.issuer);
|
||||
assertIssuer(s.issuer);
|
||||
const insecure = insecureOk(issuer);
|
||||
const found = await oidc.discovery(issuer, s.clientId, undefined, oidc.None(), {
|
||||
timeout: 5,
|
||||
...(insecure && { execute: [oidc.allowInsecureRequests] }),
|
||||
});
|
||||
const meta = found.serverMetadata();
|
||||
const secret = s.clientSecretEnc ? decrypt(s.clientSecretEnc) : null;
|
||||
const methods = meta.token_endpoint_auth_methods_supported ?? ['client_secret_basic'];
|
||||
const auth = !secret
|
||||
? oidc.None()
|
||||
: methods.includes('client_secret_basic') || !methods.includes('client_secret_post')
|
||||
? oidc.ClientSecretBasic(secret)
|
||||
: oidc.ClientSecretPost(secret);
|
||||
const config = new oidc.Configuration(meta, s.clientId, undefined, auth);
|
||||
config.timeout = 5;
|
||||
// Check ID token signatures too (by default TLS to the token endpoint is trusted instead).
|
||||
oidc.enableNonRepudiationChecks(config);
|
||||
if (insecure) oidc.allowInsecureRequests(config);
|
||||
cached = { key, config };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** What the sp_oidc cookie holds between start and callback (12 §3.2). */
|
||||
export type PendingSignIn = {
|
||||
state: string;
|
||||
nonce: string;
|
||||
verifier: string;
|
||||
next: string;
|
||||
/** Connecting from My account: the session that asked (12 §3.3). */
|
||||
connectSessionId: string | null;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export async function startSignIn(
|
||||
s: OidcSettings,
|
||||
host: string,
|
||||
next: string,
|
||||
connectSessionId: string | null,
|
||||
): Promise<{ url: URL; pending: PendingSignIn }> {
|
||||
const config = await oidcConfig(s);
|
||||
const pending: PendingSignIn = {
|
||||
state: oidc.randomState(),
|
||||
nonce: oidc.randomNonce(),
|
||||
verifier: oidc.randomPKCECodeVerifier(),
|
||||
next,
|
||||
connectSessionId,
|
||||
exp: Date.now() + 600_000,
|
||||
};
|
||||
const url = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri(host),
|
||||
scope: s.scopes,
|
||||
state: pending.state,
|
||||
nonce: pending.nonce,
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(pending.verifier),
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return { url, pending };
|
||||
}
|
||||
|
||||
export type SignInResult = {
|
||||
/** "<iss> <sub>" (12 §2). */
|
||||
subject: string;
|
||||
/** The ID token's claims, then userinfo's on top. */
|
||||
claims: Record<string, unknown>;
|
||||
idToken: string;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
/** Checks the provider's answer (state, PKCE, nonce, ID token) and reads userinfo. */
|
||||
export async function finishSignIn(
|
||||
s: OidcSettings,
|
||||
currentUrl: URL,
|
||||
pending: PendingSignIn,
|
||||
): Promise<SignInResult> {
|
||||
const config = await oidcConfig(s);
|
||||
const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.verifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
const id = tokens.claims()!;
|
||||
let claims: Record<string, unknown> = { ...id };
|
||||
// Several providers put email or groups only in userinfo (12 §3.2 step 4).
|
||||
if (config.serverMetadata().userinfo_endpoint)
|
||||
claims = { ...claims, ...(await oidc.fetchUserInfo(config, tokens.access_token, id.sub)) };
|
||||
return {
|
||||
subject: `${id.iss} ${id.sub}`,
|
||||
claims,
|
||||
idToken: tokens.id_token!,
|
||||
refreshToken: tokens.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** RP-initiated logout (12 §3.5): where to send the browser, or null when the provider can't. */
|
||||
export async function endSessionUrl(
|
||||
s: OidcSettings,
|
||||
host: string,
|
||||
idToken: string,
|
||||
): Promise<string | null> {
|
||||
const config = await oidcConfig(s);
|
||||
if (!config.serverMetadata().end_session_endpoint) return null;
|
||||
return oidc
|
||||
.buildEndSessionUrl(config, {
|
||||
id_token_hint: idToken,
|
||||
post_logout_redirect_uri: postLogoutUri(host),
|
||||
})
|
||||
.toString();
|
||||
}
|
||||
|
||||
/** A provider error as one line for the log and the admin (never a token). */
|
||||
export function oidcError(err: unknown): string {
|
||||
if (err instanceof oidc.ResponseBodyError)
|
||||
return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
|
||||
if (err instanceof oidc.AuthorizationResponseError)
|
||||
return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
|
||||
return (err as Error).message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Sign-in → Test (12 §3.1): discovery, then a code grant with a code that can't
|
||||
* exist: `invalid_grant` means the client id and secret were accepted.
|
||||
*/
|
||||
export async function testOidc(s: OidcSettings, host: string): Promise<OidcTestResult> {
|
||||
try {
|
||||
cached = null;
|
||||
const config = await oidcConfig(s);
|
||||
const meta = config.serverMetadata();
|
||||
try {
|
||||
await oidc.genericGrantRequest(config, 'authorization_code', {
|
||||
code: 'selfphoneng-test-not-a-code',
|
||||
redirect_uri: redirectUri(host),
|
||||
code_verifier: oidc.randomPKCECodeVerifier(),
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof oidc.ResponseBodyError ? err.error : null;
|
||||
if (code === 'invalid_client' || code === 'unauthorized_client')
|
||||
return { ok: false, error: `The provider refused the client ID or secret (${code})` };
|
||||
if (code !== 'invalid_grant' && code !== 'invalid_request')
|
||||
return { ok: false, error: `The token endpoint answered: ${oidcError(err)}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
issuer: meta.issuer,
|
||||
algorithms: meta.id_token_signing_alg_values_supported ?? [],
|
||||
pkce: meta.code_challenge_methods_supported?.includes('S256') ?? false,
|
||||
refreshTokens: meta.grant_types_supported?.includes('refresh_token') ?? false,
|
||||
endSession: Boolean(meta.end_session_endpoint),
|
||||
redirectUri: redirectUri(host),
|
||||
postLogoutRedirectUri: postLogoutUri(host),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: oidcError(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the provider says about a user, applied (12 §3.4), and the leaver check (12 §3.5): every
|
||||
// OpenID session is refreshed at the provider every 15 minutes, and ends when it says no.
|
||||
import { and, eq, gt, isNotNull, isNull, lt, or } from 'drizzle-orm';
|
||||
import * as oidc from 'openid-client';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { claimAt, decide, type ClaimsDecision } from './claims.ts';
|
||||
import { applyRole } from './role.ts';
|
||||
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
|
||||
|
||||
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
|
||||
|
||||
/** The role from adminGroups (12 §3.4), and the groups and email on the identity. */
|
||||
export async function applyClaims(
|
||||
db: Db,
|
||||
log: Log,
|
||||
s: OidcSettings,
|
||||
userId: string,
|
||||
d: ClaimsDecision,
|
||||
email: string | null,
|
||||
actorIp: string | null,
|
||||
): Promise<void> {
|
||||
await applyRole(db, log, userId, d.admin, `${s.label}'s groups`, actorIp);
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email, groups: d.groups })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
}
|
||||
|
||||
const CHECK_EVERY_MS = 15 * 60_000;
|
||||
|
||||
/** One run of the oidcCheck job (every 5 minutes): sessions not checked for 15 minutes. */
|
||||
export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Promise<void> {
|
||||
const { db, log } = d;
|
||||
const now = d.now ?? new Date();
|
||||
const s = await getSetting(db, 'auth.oidc');
|
||||
if (!s) return;
|
||||
const due = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
userId: sessions.userId,
|
||||
refreshEnc: sessions.idpRefreshEnc,
|
||||
email: users.email,
|
||||
subject: userIdentities.subject,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.leftJoin(
|
||||
userIdentities,
|
||||
and(eq(userIdentities.userId, sessions.userId), eq(userIdentities.kind, 'oidc')),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.method, 'oidc'),
|
||||
isNotNull(sessions.idpRefreshEnc),
|
||||
gt(sessions.expiresAt, now),
|
||||
or(
|
||||
isNull(sessions.idpCheckedAt),
|
||||
lt(sessions.idpCheckedAt, new Date(now.getTime() - CHECK_EVERY_MS)),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!due.length) return;
|
||||
let config: oidc.Configuration;
|
||||
try {
|
||||
config = await oidcConfig(s);
|
||||
} catch (err) {
|
||||
log.warn?.({ err: oidcError(err) }, 'OpenID check skipped: the provider is unreachable');
|
||||
return;
|
||||
}
|
||||
|
||||
const end = async (row: (typeof due)[number], why: string) => {
|
||||
await db.delete(sessions).where(eq(sessions.id, row.id));
|
||||
await audit(db, {
|
||||
actorUserId: row.userId,
|
||||
action: 'logout',
|
||||
entityType: 'user',
|
||||
entityId: row.userId,
|
||||
summary: `${row.email} signed out: ${why}`,
|
||||
});
|
||||
};
|
||||
|
||||
for (const row of due) {
|
||||
// Disconnected since (Disconnect also ends these sessions; this is the belt to its braces).
|
||||
if (!row.subject) {
|
||||
await end(row, `no ${s.label} account connected any more`);
|
||||
continue;
|
||||
}
|
||||
let tokens: Awaited<ReturnType<typeof oidc.refreshTokenGrant>>;
|
||||
let claims: Record<string, unknown>;
|
||||
try {
|
||||
tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
|
||||
const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
|
||||
// As at sign-in: the ID token's claims, userinfo's on top. Entra's app roles and
|
||||
// "ID token only" mappers are in the ID token alone.
|
||||
claims = {
|
||||
...tokens.claims(),
|
||||
...(config.serverMetadata().userinfo_endpoint &&
|
||||
(await oidc.fetchUserInfo(config, tokens.access_token, sub))),
|
||||
};
|
||||
} catch (err) {
|
||||
// The provider said no: disabled, signed out there, or the refresh token expired.
|
||||
if (err instanceof oidc.ResponseBodyError && err.error === 'invalid_grant') {
|
||||
await end(row, `${s.label} ended the session (${oidcError(err)})`);
|
||||
continue;
|
||||
}
|
||||
// Down, 5xx, a bad client setup: keep the session and try again next run.
|
||||
log.warn?.({ err: oidcError(err) }, `OpenID check of a session failed`);
|
||||
continue;
|
||||
}
|
||||
// No groups claim at all (the refresh brought no ID token, and userinfo doesn't carry it):
|
||||
// nothing to decide groups on, so not "in no groups". Checked again next run.
|
||||
const groupsKnown = claimAt(claims, s.groupsClaim) !== undefined;
|
||||
const ruled = s.allowedGroups.length > 0 || s.adminGroups.length > 0;
|
||||
if (ruled && !groupsKnown) {
|
||||
log.info({ userId: row.userId }, 'OpenID check: no groups claim, role and access kept');
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
idpCheckedAt: now,
|
||||
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
|
||||
})
|
||||
.where(eq(sessions.id, row.id));
|
||||
continue;
|
||||
}
|
||||
const verdict = decide(s, claims);
|
||||
if (!verdict.allowed) {
|
||||
await end(row, `no longer in ${s.label}'s allowed groups`);
|
||||
continue;
|
||||
}
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
idpCheckedAt: now,
|
||||
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
|
||||
...(tokens.id_token && { idpIdTokenEnc: encrypt(tokens.id_token) }),
|
||||
})
|
||||
.where(eq(sessions.id, row.id));
|
||||
const email = typeof claims.email === 'string' ? claims.email : null;
|
||||
await applyClaims(db, log, s, row.userId, verdict, email, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The role a provider says (12 §1): OpenID adminGroups or the LDAP adminFilter.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
import { isLastAdmin } from '../routes/users.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
|
||||
/** admin null: the role is managed here. Never demotes the last admin with a password. */
|
||||
export async function applyRole(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
userId: string,
|
||||
admin: boolean | null,
|
||||
source: string,
|
||||
actorIp: string | null,
|
||||
): Promise<void> {
|
||||
if (admin === null) return;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
const role = admin ? 'admin' : 'user';
|
||||
if (!user || role === user.role) return;
|
||||
if (role === 'user' && (await isLastAdmin(db, userId))) {
|
||||
log.info({ userId }, `kept the last admin with a password, though ${source} says user`);
|
||||
return;
|
||||
}
|
||||
await db.update(users).set({ role }).where(eq(users.id, userId));
|
||||
await audit(db, {
|
||||
actorIp: actorIp ?? undefined,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `${user.email} is now ${role}, from ${source}`,
|
||||
diff: { before: { role: user.role }, after: { role } },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
|
||||
// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
|
||||
// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
|
||||
// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
|
||||
// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
|
||||
// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
|
||||
// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
|
||||
// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
|
||||
// diegogslomp/samba-ad-dc
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Attribute, Client } from 'ldapts';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { LDAP_PRESETS } from '@selfphoneng/shared';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { userIdentities, users } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { H, createTestApp } from './app.ts';
|
||||
|
||||
const URL_ = process.env.AD_LIVE_URL;
|
||||
const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
|
||||
const DC = 'DC=example,DC=com';
|
||||
const BASE = `CN=Users,${DC}`;
|
||||
const ADMIN_DN = `CN=Administrator,${BASE}`;
|
||||
|
||||
describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
|
||||
const tag = randomBytes(3).toString('hex');
|
||||
const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const team = `CN=team-${tag},${BASE}`;
|
||||
const admins = `CN=pbx-admins-${tag},${BASE}`;
|
||||
const dn = (sam: string) => `CN=${sam},${BASE}`;
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let ad: Client;
|
||||
|
||||
const login = (who: string, password: string) =>
|
||||
t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { login: who, password },
|
||||
});
|
||||
const row = async (sam: string) =>
|
||||
(
|
||||
await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${sam}@example.com`))
|
||||
)[0]!;
|
||||
|
||||
beforeAll(async () => {
|
||||
ad = new Client({ url: URL_! });
|
||||
await ad.bind(ADMIN_DN, ADMIN_PASS);
|
||||
// userAccountControl 512: a normal account; 514: the same, disabled.
|
||||
for (const [sam, uac] of [
|
||||
[ann, '512'],
|
||||
[bob, '512'],
|
||||
[dan, '514'],
|
||||
[cat, '512'],
|
||||
] as const)
|
||||
await ad.add(dn(sam), [
|
||||
...Object.entries({
|
||||
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
|
||||
sAMAccountName: [sam],
|
||||
userPrincipalName: [`${sam}@example.com`],
|
||||
mail: [`${sam}@example.com`],
|
||||
displayName: [sam],
|
||||
userAccountControl: [uac],
|
||||
}).map(([type, values]) => new Attribute({ type, values })),
|
||||
// AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
|
||||
new Attribute({
|
||||
type: 'unicodePwd',
|
||||
values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
|
||||
}),
|
||||
]);
|
||||
// ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
|
||||
await ad.add(team, {
|
||||
objectClass: ['top', 'group'],
|
||||
sAMAccountName: `team-${tag}`,
|
||||
member: dn(ann),
|
||||
});
|
||||
await ad.add(admins, {
|
||||
objectClass: ['top', 'group'],
|
||||
sAMAccountName: `pbx-admins-${tag}`,
|
||||
member: team,
|
||||
});
|
||||
|
||||
t = await createTestApp();
|
||||
// New users with the temporary password: the directory email may connect them (12 §4.3).
|
||||
for (const sam of [ann, bob, dan, cat]) {
|
||||
await t.addUser(`${sam}@example.com`, 'local-password-1');
|
||||
await t.db
|
||||
.update(users)
|
||||
.set({ mustChangePassword: true })
|
||||
.where(eq(users.email, `${sam}@example.com`));
|
||||
}
|
||||
await setSetting(t.db, 'auth.ldap', {
|
||||
enabled: true,
|
||||
preset: 'ad',
|
||||
url: URL_!,
|
||||
startTls: false,
|
||||
bindDn: ADMIN_DN,
|
||||
bindPasswordEnc: encrypt(ADMIN_PASS),
|
||||
baseDn: BASE,
|
||||
userFilter: LDAP_PRESETS.ad.userFilter,
|
||||
idAttribute: LDAP_PRESETS.ad.idAttribute,
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
|
||||
await ad?.del(d).catch(() => {});
|
||||
await ad?.unbind();
|
||||
});
|
||||
|
||||
it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
|
||||
resetLoginLimiter();
|
||||
const r = await login(ann, `${ann}-Directory-Pw1`);
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
|
||||
const [ident] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, (await row(ann)).id));
|
||||
expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('signs in by UPN and by mail; outside the group is a user', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
|
||||
'user',
|
||||
);
|
||||
resetLoginLimiter();
|
||||
expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('a disabled account (userAccountControl bit 2) is refused', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('directoryCheck disables a deleted user and keeps the rest', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
|
||||
await ad.del(dn(cat));
|
||||
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
|
||||
expect((await row(cat)).isActive).toBe(false);
|
||||
expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
|
||||
expect((await row(bob)).isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export async function createTestApp(
|
||||
INITIAL_ADMIN_PASSWORD: ADMIN.password,
|
||||
AMI_SECRET: 'ami-secret-123',
|
||||
LOG_LEVEL: 'fatal',
|
||||
RATE_LIMIT_PER_MINUTE: '0', // the rate limit has its own test
|
||||
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
|
||||
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
|
||||
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 11 §6 C4 against real Asterisk: a call from outside rings 101's phone with the caller's name
|
||||
// from 101's contacts. A second Asterisk plays the provider, a baresip phone is 101, and a tiny
|
||||
// stand-in for the api's /api/internal/caller-name runs in the PBX's network namespace (so it is
|
||||
// 127.0.0.1 to Asterisk, as in production). What the phone sees is read from the INVITE the PBX
|
||||
// sends it (pjsip logger).
|
||||
// Checks: a contact's name; no contact keeps the provider's name; a ring group's "Sales:" prefix
|
||||
// stays in front; the api not answering still rings with the provider's name; 101's contact name
|
||||
// does not follow a forward to 102.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/cnam-test.ts
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { providerPjsip } from './provider.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-cnam';
|
||||
const PBX = 'sp-cnam-pbx';
|
||||
const PROVIDER = 'sp-cnam-provider';
|
||||
const PHONES = 'sp-cnam-phones';
|
||||
const API = 'sp-cnam-api';
|
||||
const AMI_PORT = 15041;
|
||||
const AMI_SECRET = 'cnam-test-secret';
|
||||
const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
|
||||
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
|
||||
const TOKEN = 'ab'.repeat(32);
|
||||
const EXT = {
|
||||
'101': '00000000-0000-4000-8000-000000000101',
|
||||
'102': '00000000-0000-4000-8000-000000000102',
|
||||
};
|
||||
const PASS = { '101': 'CnamTest101xxxxxxxxxxxxx', '102': 'CnamTest102xxxxxxxxxxxxx' };
|
||||
const DID_USER = '13125551234'; // rings 101
|
||||
const DID_GROUP = '13125551235'; // "Sales:" ring group 600 → 101
|
||||
const KNOWN = '3125559999'; // Jane Doe in 101's contacts (10 digits, as voip.ms sends)
|
||||
const UNKNOWN = '3125550001';
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const log = (...a: unknown[]) => console.log('[cnam-test]', ...a);
|
||||
|
||||
function snapshot(): Snapshot {
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
|
||||
) as Snapshot['settings'];
|
||||
const ext = (number: '101' | '102', name: string) => ({
|
||||
id: EXT[number],
|
||||
number,
|
||||
name,
|
||||
sipPassword: PASS[number],
|
||||
webrtcEnabled: false,
|
||||
maxContacts: 5,
|
||||
ringSeconds: 6, // the forward check waits for 101's no-answer
|
||||
voicemailEnabled: false,
|
||||
voicemailPin: '4826',
|
||||
outboundClass: 'national' as const,
|
||||
outboundCallerId: null,
|
||||
emergencyCallerId: null,
|
||||
recordMode: 'never' as const,
|
||||
pickupGroup: null,
|
||||
language: 'en',
|
||||
mohClass: 'default',
|
||||
isAdmin: false,
|
||||
});
|
||||
return {
|
||||
today: '2026-10-23',
|
||||
settings,
|
||||
callerNames: { url: 'http://127.0.0.1:8080/api/internal/caller-name', token: TOKEN },
|
||||
dids: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
number: DID_USER,
|
||||
trunkId: TRUNK,
|
||||
label: null,
|
||||
destination: { type: 'user', id: EXT['101'] },
|
||||
callerIdNamePrefix: null,
|
||||
recordInbound: false,
|
||||
},
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111112',
|
||||
number: DID_GROUP,
|
||||
trunkId: TRUNK,
|
||||
label: null,
|
||||
destination: { type: 'ring_group', id: '22222222-2222-4222-8222-222222222222' },
|
||||
callerIdNamePrefix: null,
|
||||
recordInbound: false,
|
||||
},
|
||||
],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
trunks: [
|
||||
{
|
||||
id: TRUNK,
|
||||
name: 'voip.ms',
|
||||
enabled: true,
|
||||
serverHost: PROVIDER,
|
||||
serverPort: 5060,
|
||||
transport: 'udp',
|
||||
authMode: 'registration',
|
||||
username: SUB.user,
|
||||
password: SUB.pass,
|
||||
fromUser: null,
|
||||
fromDomain: null,
|
||||
codecs: ['ulaw'],
|
||||
srtp: false,
|
||||
maxChannels: null,
|
||||
},
|
||||
],
|
||||
ringGroups: [
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
number: '600',
|
||||
name: 'Sales',
|
||||
strategy: 'ringall',
|
||||
ringSeconds: 20,
|
||||
callerIdNamePrefix: 'Sales:',
|
||||
musicInsteadOfRing: false,
|
||||
members: ['101'],
|
||||
noAnswerDestination: { type: 'hangup' },
|
||||
},
|
||||
],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
recordingAnnouncement: null,
|
||||
routes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The provider: "Mobile" <caller> calls one of our DIDs; ${CALLER} picks who calls. */
|
||||
const providerDialplan = `[provider-out]
|
||||
exten => _X.,1,Set(CALLERID(all)="Mobile" <\${CALLER}>)
|
||||
same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)
|
||||
|
||||
[provider-wait]
|
||||
exten => s,1,Answer()
|
||||
same => n,Wait(30)
|
||||
|
||||
[provider-in]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,Wait(30)
|
||||
`;
|
||||
|
||||
/** baresip phones as 101 and 102: they ring, nobody answers (the INVITE is what we read). */
|
||||
function phoneFiles(dir: string) {
|
||||
for (const n of ['101', '102'] as const) {
|
||||
const d = join(dir, `phone-${n}`);
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(
|
||||
join(d, 'config'),
|
||||
[
|
||||
'poll_method epoll',
|
||||
`sip_listen 0.0.0.0:${n === '101' ? 5070 : 5072}`,
|
||||
'module_path /usr/lib/baresip/modules',
|
||||
'module g711.so',
|
||||
'module ausine.so',
|
||||
'module aufile.so',
|
||||
'module menu.so',
|
||||
'module account.so',
|
||||
'audio_source ausine,440',
|
||||
'audio_player aufile,/dev/null',
|
||||
'audio_alert aufile,/dev/null',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
writeFileSync(
|
||||
join(d, 'accounts'),
|
||||
`<sip:${n}@${PBX};transport=udp>;auth_pass=${PASS[n]};answermode=manual;audio_codecs=PCMU;regint=60\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The api's caller-name route, faked: Jane Doe for KNOWN in 101's book, with the right key. */
|
||||
const FAKE_API = `
|
||||
const http = require('http');
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const ok = u.pathname === '/api/internal/caller-name' && u.searchParams.get('token') === '${TOKEN}';
|
||||
const name = ok && u.searchParams.get('ext') === '101' && u.searchParams.get('number') === '1${KNOWN}' ? 'Jane Doe' : '';
|
||||
console.log('lookup ' + u.searchParams.get('ext') + ' ' + u.searchParams.get('number') + ' ' + (ok ? 'key-ok' : 'key-bad') + ' -> ' + JSON.stringify(name));
|
||||
if (process.env.SLOW) return setTimeout(() => res.end(name), 5000);
|
||||
res.end(name);
|
||||
}).listen(8080, '127.0.0.1');
|
||||
`;
|
||||
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-cnam-'));
|
||||
const pbxConf = join(dir, 'pbx');
|
||||
const provConf = join(dir, 'provider');
|
||||
mkdirSync(pbxConf);
|
||||
mkdirSync(provConf);
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) {
|
||||
writeFileSync(join(pbxConf, f), files[f]);
|
||||
writeFileSync(
|
||||
join(provConf, f),
|
||||
f === 'pjsip_selfphoneng.conf'
|
||||
? providerPjsip(SUB)
|
||||
: f === 'extensions_selfphoneng.conf'
|
||||
? providerDialplan
|
||||
: '',
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
const problems: string[] = [];
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
username: 'selfphoneng',
|
||||
secret: AMI_SECRET,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
backoffMs: [500],
|
||||
});
|
||||
const asterisk = (name: string, conf: string, extra: string[]) =>
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
NET,
|
||||
...extra,
|
||||
'-e',
|
||||
`AMI_SECRET=${AMI_SECRET}`,
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
IMAGE,
|
||||
);
|
||||
const booted = (name: string) =>
|
||||
waitFor(
|
||||
`${name} to boot`,
|
||||
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
|
||||
);
|
||||
const startApi = (slow: boolean) => {
|
||||
docker('rm', '-f', API);
|
||||
const r = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
API,
|
||||
'--network',
|
||||
`container:${PBX}`,
|
||||
...(slow ? ['-e', 'SLOW=1'] : []),
|
||||
'node:22-alpine',
|
||||
'node',
|
||||
'-e',
|
||||
FAKE_API,
|
||||
);
|
||||
if (!r.ok) throw new Error(`fake api: ${r.out}`);
|
||||
};
|
||||
|
||||
/** The provider calls `did` as `caller`; returns the From display name of the INVITE to `ext`. */
|
||||
async function ring(did: string, caller: string, ext: '101' | '102'): Promise<string | null> {
|
||||
// channel originate can't set a variable: the provider's dialplan reads a global.
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', `dialplan set global CALLER ${caller}`);
|
||||
const since = new Date().toISOString();
|
||||
await ami.command('pjsip set logger on');
|
||||
const o = docker(
|
||||
'exec',
|
||||
PROVIDER,
|
||||
'asterisk',
|
||||
'-rx',
|
||||
`channel originate Local/${did.slice(1)}@provider-out extension s@provider-wait`,
|
||||
);
|
||||
if (!o.ok) throw new Error(`originate: ${o.out}`);
|
||||
let from: string | null = null;
|
||||
await waitFor(
|
||||
`the INVITE to ${ext}`,
|
||||
async () => {
|
||||
const logs = docker('logs', '--since', since, PBX).out;
|
||||
const invites = logs
|
||||
.split(/<--- Transmitting SIP request/)
|
||||
.slice(1)
|
||||
.filter((b) => new RegExp(`^INVITE sip:${ext}[-@]`, 'm').test(b));
|
||||
const m = invites.map((b) => /^From: (?:"([^"]*)" )?</m.exec(b)).find(Boolean);
|
||||
if (m) from = m[1] ?? '';
|
||||
return m !== undefined;
|
||||
},
|
||||
20,
|
||||
);
|
||||
await ami.command('pjsip set logger off');
|
||||
await ami.command('channel request hangup all');
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'channel request hangup all');
|
||||
await sleep(2000);
|
||||
return from;
|
||||
}
|
||||
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
|
||||
docker('network', 'create', NET);
|
||||
if (!asterisk(PROVIDER, provConf, []).ok) throw new Error('provider did not start');
|
||||
await booted(PROVIDER);
|
||||
const pbx = asterisk(PBX, pbxConf, [
|
||||
'-p',
|
||||
`127.0.0.1:${AMI_PORT}:5038`,
|
||||
'-v',
|
||||
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
|
||||
]);
|
||||
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
|
||||
await booted(PBX);
|
||||
startApi(false);
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
await waitFor('the trunk to register', async () =>
|
||||
(await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' })).some(
|
||||
(m) => m.Status === 'Registered',
|
||||
),
|
||||
);
|
||||
|
||||
log('starting baresip phones 101 and 102 (installs baresip on first run)…');
|
||||
const phones = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
PHONES,
|
||||
'--network',
|
||||
NET,
|
||||
'-v',
|
||||
`${dir}:/phones`,
|
||||
'debian:trixie-slim',
|
||||
'sh',
|
||||
'-c',
|
||||
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' +
|
||||
'(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && (baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 900',
|
||||
);
|
||||
if (!phones.ok) throw new Error(`phones: ${phones.out}`);
|
||||
await waitFor(
|
||||
'101 and 102 to register',
|
||||
async () => {
|
||||
const c = await ami.command('pjsip show contacts');
|
||||
return /101\/sip:.*Avail/.test(c) && /102\/sip:.*Avail/.test(c);
|
||||
},
|
||||
240,
|
||||
);
|
||||
|
||||
const expect = (what: string, got: string | null, want: string) => {
|
||||
log(`${what}: the phone shows ${JSON.stringify(got)}`);
|
||||
if (got !== want)
|
||||
problems.push(
|
||||
`${what}: phone showed ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
|
||||
);
|
||||
};
|
||||
expect('a contact calls 101', await ring(DID_USER, KNOWN, '101'), 'Jane Doe');
|
||||
expect('a stranger calls 101', await ring(DID_USER, UNKNOWN, '101'), 'Mobile');
|
||||
expect(
|
||||
'a contact calls the Sales group',
|
||||
await ring(DID_GROUP, KNOWN, '101'),
|
||||
'Sales: Jane Doe',
|
||||
);
|
||||
|
||||
// 101 doesn't answer and forwards to 102 (AstDB SP/CFNA/101, as My settings writes it): 101's
|
||||
// phone showed Jane Doe, and 102 must not see 101's private contact name.
|
||||
await ami.command('database put SP CFNA/101 102');
|
||||
expect('101 unanswered, forwarded to 102', await ring(DID_USER, KNOWN, '102'), 'Mobile');
|
||||
await ami.command('database del SP CFNA/101');
|
||||
|
||||
const lookups = docker('logs', API).out;
|
||||
if (!/lookup 101 13125559999 key-ok -> "Jane Doe"/.test(lookups))
|
||||
problems.push(`no keyed lookup for 101:\n${lookups}`);
|
||||
if (/key-bad/.test(lookups)) problems.push(`a lookup without the key:\n${lookups}`);
|
||||
|
||||
// The api too slow (5 s, the dialplan waits 2): the phone still rings, with the provider's name.
|
||||
startApi(true);
|
||||
await sleep(1000);
|
||||
const t0 = Date.now();
|
||||
expect('the api does not answer in time', await ring(DID_USER, KNOWN, '101'), 'Mobile');
|
||||
log(`rang after ${Math.round((Date.now() - t0) / 100) / 10} s with the api hanging`);
|
||||
} finally {
|
||||
ami.stop();
|
||||
if (process.env.KEEP) log(`KEEP: containers left running, config in ${dir}`);
|
||||
else {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
|
||||
docker('network', 'rm', NET);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
if (problems.length) {
|
||||
console.error(`[cnam-test] FAILED:\n- ${problems.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
log('PASS');
|
||||
}
|
||||
|
||||
main().catch((e: unknown) => {
|
||||
console.error('[cnam-test]', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The call report email (05 §4): its periods, and who gets what.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { callExtensions, calls, extensions, recordings } from '../src/db/schema.ts';
|
||||
import { lastPeriod, sendCallReports } from '../src/services/callreport.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('call report email', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let dan: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let extId: string;
|
||||
const tz = 'America/Chicago';
|
||||
const period = (callReport: 'hourly' | 'daily' | 'weekly', now: string, at = '08:00', wd = 1) =>
|
||||
lastPeriod(
|
||||
t.db,
|
||||
tz,
|
||||
{ callReport, callReportTime: at, callReportWeekday: wd },
|
||||
new Date(now),
|
||||
).then((p) => [p.from.toISOString(), p.to.toISOString()]);
|
||||
const run = (now: string) =>
|
||||
sendCallReports({ db: t.db, send: t.app.sendMail, log: t.app.log, now: new Date(now) });
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
await setSetting(t.db, 'general.timezone', tz);
|
||||
const as = (cookie: string) => (method: string, url: string, payload?: unknown) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||||
const danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
extId = (
|
||||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId })
|
||||
).json().id;
|
||||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('periods end at the last scheduled local time, DST included', async () => {
|
||||
// CDT is UTC-5. 2026-10-23 is a Friday.
|
||||
expect(await period('hourly', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-23T13:00:00.000Z',
|
||||
'2026-10-23T14:00:00.000Z',
|
||||
]);
|
||||
expect(await period('daily', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-22T13:00:00.000Z',
|
||||
'2026-10-23T13:00:00.000Z',
|
||||
]);
|
||||
// 07:59 local: still yesterday's period.
|
||||
expect(await period('daily', '2026-10-23T12:59:00Z')).toEqual([
|
||||
'2026-10-21T13:00:00.000Z',
|
||||
'2026-10-22T13:00:00.000Z',
|
||||
]);
|
||||
// Weekly on Monday 08:00, asked on Friday: the week up to this Monday.
|
||||
expect(await period('weekly', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-12T13:00:00.000Z',
|
||||
'2026-10-19T13:00:00.000Z',
|
||||
]);
|
||||
// DST ends 02:00 Sunday 2026-11-01 (CDT to CST, UTC-6): Saturday 08:00 to Sunday 08:00 is 25 h.
|
||||
expect(await period('daily', '2026-11-01T15:00:00Z')).toEqual([
|
||||
'2026-10-31T13:00:00.000Z',
|
||||
'2026-11-01T14:00:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('PATCH /api/me saves the schedule, starting with the next period', async () => {
|
||||
const r = await dan('PATCH', '/api/me', {
|
||||
callReport: 'daily',
|
||||
callReportTime: '17:30',
|
||||
callReportSummaries: true,
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(r.json()).toMatchObject({
|
||||
callReport: 'daily',
|
||||
callReportTime: '17:30',
|
||||
callReportWeekday: 1,
|
||||
callReportSummaries: true,
|
||||
});
|
||||
const [e] = await t.db.select().from(extensions).where(eq(extensions.id, extId));
|
||||
expect(e!.callReportSentAt).not.toBeNull();
|
||||
expect((await dan('PATCH', '/api/me', { callReportTime: '25:00' })).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('emails the calls of the period once, and nothing for a period without calls', async () => {
|
||||
await dan('PATCH', '/api/me', { callReport: 'daily', callReportTime: '08:00' });
|
||||
await t.db.update(extensions).set({ callReportSentAt: null }).where(eq(extensions.id, extId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'r1',
|
||||
filePath: '2026/10/22/r1.wav',
|
||||
startedAt: new Date('2026-10-22T15:00:00Z'),
|
||||
summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
|
||||
aiDoneAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [inCall, outCall, before] = await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
{
|
||||
linkedid: 'c1',
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-22T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-22T15:03:12Z'),
|
||||
fromNumber: '13125551234',
|
||||
fromName: 'Acme',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 192,
|
||||
answeredByExtensionId: extId,
|
||||
recordingId: rec!.id,
|
||||
},
|
||||
{
|
||||
linkedid: 'c2',
|
||||
direction: 'outbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-22T20:00:00Z'),
|
||||
endedAt: new Date('2026-10-22T20:00:45Z'),
|
||||
fromNumber: '101',
|
||||
toNumber: '18005550100',
|
||||
talkSeconds: 40,
|
||||
},
|
||||
{
|
||||
linkedid: 'c3', // the period before
|
||||
direction: 'inbound',
|
||||
status: 'missed',
|
||||
startedAt: new Date('2026-10-21T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-21T15:00:20Z'),
|
||||
fromNumber: '13125559999',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values([
|
||||
{ callId: inCall!.id, extensionId: extId, role: 'answered' },
|
||||
{ callId: outCall!.id, extensionId: extId, role: 'caller' },
|
||||
{ callId: before!.id, extensionId: extId, role: 'rang' },
|
||||
]);
|
||||
|
||||
t.mails.length = 0;
|
||||
// Today 08:00 CDT is 13:00Z; the report waits a few minutes for the last calls and summaries.
|
||||
expect(await run('2026-10-23T13:02:00Z')).toBe(1); // yesterday's period, never reported
|
||||
t.mails.length = 0;
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
expect(await run('2026-10-23T13:06:00Z')).toBe(1);
|
||||
expect(await run('2026-10-23T13:07:00Z')).toBe(0); // once
|
||||
expect(t.mails).toHaveLength(1);
|
||||
const m = t.mails[0]!;
|
||||
expect(m.to).toEqual(['dan@example.com']);
|
||||
expect(m.subject).toBe('Your calls (101): 2 calls');
|
||||
expect(m.text).toContain('Incoming from (312) 555-1234 (Acme) to (312) 555-0000');
|
||||
expect(m.text).toContain('answered · 3:12 · recorded');
|
||||
expect(m.text).toContain(' - Dan sends the quote');
|
||||
expect(m.text).toContain('Outgoing to (800) 555-0100');
|
||||
expect(m.text).toContain('answered · 0:40\n');
|
||||
expect(m.text).not.toContain('555-9999');
|
||||
expect(m.text.indexOf('Incoming')).toBeLessThan(m.text.indexOf('Outgoing'));
|
||||
|
||||
// The next day had no calls: no email.
|
||||
expect(await run('2026-10-24T13:06:00Z')).toBe(0);
|
||||
expect(t.mails).toHaveLength(1);
|
||||
|
||||
// Summaries off: the calls, not what was said.
|
||||
await dan('PATCH', '/api/me', { callReportSummaries: false });
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
await run('2026-10-23T13:06:00Z');
|
||||
expect(t.mails[1]!.text).not.toContain('quote');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C1: my contacts, the company's, imports and shares (spec 11).
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { callerNameToken, phoneSafeName } from '../src/contacts/callername.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
|
||||
describe.skipIf(!hasTestDb)('contacts', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: Req;
|
||||
let dan: Req;
|
||||
let sue: Req;
|
||||
let danId: string;
|
||||
const vcf = (name: string, tel: string) =>
|
||||
`BEGIN:VCARD\r\nVERSION:3.0\r\nFN:${name}\r\nTEL;TYPE=CELL:${tel}\r\nEND:VCARD\r\n`;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const as =
|
||||
(cookie: string): Req =>
|
||||
(method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||||
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
|
||||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
|
||||
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
|
||||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||||
sue = as(await t.login('sue@example.com', 'sue-password-123'));
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('my book and the company book: numbers normalized, users only edit their own', async () => {
|
||||
const mine = await dan('POST', '/api/me/contacts', {
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme',
|
||||
email: '',
|
||||
numbers: [{ label: 'mobile', number: '(312) 555-1234' }],
|
||||
});
|
||||
expect(mine.statusCode, mine.body).toBe(201);
|
||||
expect(mine.json()).toMatchObject({
|
||||
book: 'mine',
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '13125551234' }],
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await dan('POST', '/api/me/contacts', { name: 'X', numbers: [{ number: 'call me' }] })
|
||||
).json().error.message,
|
||||
).toMatch(/isn't a phone number/);
|
||||
|
||||
const co = await admin('POST', '/api/contacts', {
|
||||
name: 'Pizza Place',
|
||||
numbers: [{ label: 'main', number: '312-555-0000' }],
|
||||
});
|
||||
expect(co.statusCode, co.body).toBe(201);
|
||||
expect((await dan('GET', '/api/contacts')).statusCode).toBe(403);
|
||||
// Dan sees the company contact but can't change it.
|
||||
const list = (await dan('GET', '/api/me/contacts')).json().items;
|
||||
expect(list.map((c: { name: string; book: string }) => [c.name, c.book])).toEqual([
|
||||
['Jane Doe', 'mine'],
|
||||
['Pizza Place', 'company'],
|
||||
]);
|
||||
expect((await dan('DELETE', `/api/me/contacts/${co.json().id}`)).statusCode).toBe(404);
|
||||
expect(
|
||||
(await sue('PATCH', `/api/me/contacts/${mine.json().id}`, { name: 'Mine now' })).statusCode,
|
||||
).toBe(404);
|
||||
expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(1); // company only
|
||||
});
|
||||
|
||||
it('import: preview first, then save; what is already there is skipped', async () => {
|
||||
const files = [
|
||||
{
|
||||
name: 'phone.vcf',
|
||||
text:
|
||||
vcf('Jane Doe', '+1 312 555 1234') + // already in Dan's book
|
||||
vcf('Bob Smith', '312.555.9999') +
|
||||
'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nobody Home\r\nEMAIL:n@example.com\r\nEND:VCARD\r\n',
|
||||
},
|
||||
{ name: 'more.csv', text: 'name,number1\nBob Smith,3125559999\nCarl,12ab\n' },
|
||||
];
|
||||
const preview = await dan('POST', '/api/me/contacts/import', { files });
|
||||
expect(preview.json()).toEqual({
|
||||
committed: false,
|
||||
added: 3,
|
||||
duplicates: 2,
|
||||
noNumber: 2,
|
||||
errors: ['Carl: "12ab" isn\'t a phone number, left out'],
|
||||
});
|
||||
expect((await dan('GET', '/api/me/contacts')).json().items).toHaveLength(2);
|
||||
const saved = await dan('POST', '/api/me/contacts/import?commit=1', { files });
|
||||
expect(saved.json()).toMatchObject({ committed: true, added: 3, duplicates: 2 });
|
||||
expect((await dan('POST', '/api/me/contacts/import?commit=1', { files })).json()).toMatchObject(
|
||||
{
|
||||
added: 0,
|
||||
duplicates: 5,
|
||||
},
|
||||
);
|
||||
// An admin imports into the company book.
|
||||
const co = await admin('POST', '/api/contacts/import?commit=1', {
|
||||
files: [{ name: 'co.vcf', text: vcf('Plumber', '3125557777') }],
|
||||
});
|
||||
expect(co.json()).toMatchObject({ added: 1 });
|
||||
expect(
|
||||
(await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
|
||||
).toEqual(['Pizza Place', 'Plumber']);
|
||||
});
|
||||
|
||||
it('sharing offers a copy; nothing lands until Accept, Deny adds nothing', async () => {
|
||||
const book = (await dan('GET', '/api/me/contacts')).json().items as {
|
||||
id: string;
|
||||
name: string;
|
||||
book: string;
|
||||
}[];
|
||||
const jane = book.find((c) => c.name === 'Jane Doe')!;
|
||||
const bob = book.find((c) => c.name === 'Bob Smith')!;
|
||||
expect(
|
||||
(
|
||||
await dan('POST', '/api/me/contacts/share', {
|
||||
contactIds: [book.find((c) => c.book === 'company')!.id],
|
||||
everyone: true,
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(404); // not Dan's to share
|
||||
const r = await dan('POST', '/api/me/contacts/share', {
|
||||
contactIds: [jane.id, bob.id],
|
||||
extensions: ['102'],
|
||||
});
|
||||
expect(r.json()).toEqual({ offers: 2, people: 1 });
|
||||
// Shared again: still one offer per contact.
|
||||
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id, bob.id], everyone: true });
|
||||
|
||||
const before = (await sue('GET', '/api/me/contacts')).json().items.length;
|
||||
const offers = (await sue('GET', '/api/me/contacts/shares')).json().items as {
|
||||
id: string;
|
||||
from: { name: string };
|
||||
contact: { name: string };
|
||||
}[];
|
||||
expect(offers.map((o) => [o.from.name, o.contact.name]).sort()).toEqual([
|
||||
['dan@example.com', 'Bob Smith'],
|
||||
['dan@example.com', 'Jane Doe'],
|
||||
]);
|
||||
expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(before);
|
||||
|
||||
const janeOffer = offers.find((o) => o.contact.name === 'Jane Doe')!;
|
||||
const bobOffer = offers.find((o) => o.contact.name === 'Bob Smith')!;
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/deny', { ids: [bobOffer.id] })).json(),
|
||||
).toEqual({ denied: 1 });
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [janeOffer.id] })).json(),
|
||||
).toEqual({
|
||||
added: 1,
|
||||
duplicates: 0,
|
||||
});
|
||||
// Decided offers can't be decided again, or by someone else.
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [bobOffer.id] })).json(),
|
||||
).toEqual({
|
||||
added: 0,
|
||||
duplicates: 0,
|
||||
});
|
||||
const names = (await sue('GET', '/api/me/contacts'))
|
||||
.json()
|
||||
.items.map((c: { name: string }) => c.name);
|
||||
expect(names).toContain('Jane Doe');
|
||||
expect(names).not.toContain('Bob Smith');
|
||||
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
|
||||
// The copy is Sue's own: Dan editing his doesn't change it.
|
||||
await dan('PATCH', `/api/me/contacts/${jane.id}`, { name: 'Jane D.', numbers: [] });
|
||||
expect(
|
||||
(await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
|
||||
).toContain('Jane Doe');
|
||||
});
|
||||
|
||||
it('looks up names: my book first, then the company', async () => {
|
||||
await admin('POST', '/api/contacts', {
|
||||
name: 'Front desk',
|
||||
numbers: [{ number: '3125551234' }],
|
||||
});
|
||||
await dan('POST', '/api/me/contacts', { name: 'My Jane', numbers: [{ number: '3125551234' }] });
|
||||
const names = await contactNames(t.db, danId, [
|
||||
'13125551234',
|
||||
'13125550000',
|
||||
'19995550000',
|
||||
null,
|
||||
]);
|
||||
expect(Object.fromEntries(names)).toEqual({
|
||||
'13125551234': 'My Jane',
|
||||
'13125550000': 'Pizza Place',
|
||||
});
|
||||
const company = await contactNames(t.db, null, ['13125551234']);
|
||||
expect(company.get('13125551234')).toBe('Front desk');
|
||||
});
|
||||
|
||||
it('the dialplan lookup (C4): only with the key, never through the proxy, phone-safe names', async () => {
|
||||
const token = callerNameToken(t.app.config.APP_SECRET);
|
||||
const look = (q: string, headers: Record<string, string> = {}) =>
|
||||
t.app.inject({ method: 'GET', url: `/api/internal/caller-name?${q}`, headers });
|
||||
const mine = await look(`ext=101&number=13125551234&token=${token}`);
|
||||
expect([mine.statusCode, mine.body]).toEqual([200, 'My Jane']);
|
||||
expect((await look(`ext=102&number=13125551234&token=${token}`)).body).toBe('Jane Doe'); // Sue's own copy, from the accepted share
|
||||
expect((await look(`ext=999&number=13125551234&token=${token}`)).body).toBe('Front desk'); // no user
|
||||
expect((await look(`ext=101&number=19995550000&token=${token}`)).body).toBe('');
|
||||
expect((await look('ext=101&number=13125551234&token=wrong')).statusCode).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await look(`ext=101&number=13125551234&token=${token}`, {
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(404);
|
||||
expect(phoneSafeName(String.raw`Jane "J" <Doe>, Inc.; (x)^|\ Ünïcode-Smith`)).toBe(
|
||||
'Jane J Doe Inc. x Ünïcode-Smith',
|
||||
);
|
||||
expect(phoneSafeName('A'.repeat(60))).toHaveLength(40);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user