60 Commits
Author SHA1 Message Date
iamdoubz 10b499e263 Merge pull request 'Security/freepbx campaign' (#10) from security/freepbx-campaign into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 4s
release / images (release) Successful in 59s
Reviewed-on: #10
2026-10-29 18:48:11 -05:00
iamdoubz 34f443385d release: version 0.0.13
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 4s
2026-10-29 18:40:30 -05:00
iamdoubz f435b475ed repo: ignore voip.ms's API example bundle
ci / check (push) Successful in 1m19s
ci / images (push) Successful in 1m30s
2026-10-29 18:35:06 -05:00
iamdoubz 1979a02827 voipms: IP restriction on the setup checklist, getCDR checked against voip.ms's examples 2026-10-29 18:35:06 -05:00
iamdoubz 08bc611dec security: fraud alerts (M11-T10), admin access spec (M11-T9) 2026-10-29 18:35:06 -05:00
iamdoubz 63286a1954 asterisk: AMI limited to the classes we use, no shell-running modules in the image 2026-10-29 18:35:06 -05:00
iamdoubz 6bd2aaec56 Merge pull request 'Asterisk/24.0.0 rc3' (#9) from asterisk/24.0.0-rc3 into main
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 48s
release / images (release) Successful in 2m10s
Reviewed-on: #9
2026-10-28 17:23:19 -05:00
iamdoubz 80e97b4118 release: version 0.0.12
ci / check (push) Successful in 1m25s
ci / images (push) Successful in 2m35s
ci / check (pull_request) Successful in 1m13s
ci / images (pull_request) Successful in 5s
2026-10-28 17:13:31 -05:00
iamdoubz 0a18d5354d asterisk: 24.0.0-rc3, TLS trunks qualified again
rc3 checks a qualify's certificate against the host name, so TLS trunks get
qualify_frequency = 60 and Test qualifies them. The upgrade check and
pnpm test:asterisk pass on rc3. Spec 01's copy of the Dockerfile is in sync
again, and the version test now compares all of it.
2026-10-28 17:11:18 -05:00
iamdoubz a0121d4975 test: Asterisk upgrade check
verified.ts records what the code relies on Asterisk doing and the version it
was measured on; pnpm test fails while the Dockerfile pins another one.
pnpm test:asterisk-upgrade boots the image next to a TLS provider and lists
every behaviour that moved and what depends on it.
2026-10-28 17:01:26 -05:00
iamdoubz 678d45e4f9 Merge pull request 'Security/review 2' (#8) from security/review-2 into main
ci / check (push) Successful in 1m14s
ci / images (push) Successful in 53s
Reviewed-on: #8
2026-10-28 16:59:55 -05:00
iamdoubz 60348cf0c5 security: a key for the network test probe (08 §7 #9)
ci / check (push) Successful in 1m13s
ci / images (push) Successful in 4m2s
ci / check (pull_request) Successful in 1m42s
ci / images (pull_request) Successful in 28s
Settings → Network makes a key, shown once and stored encrypted; the api sends
it to the probe as a Bearer token, and a new probe address drops it. A probe
with PROBE_KEY only answers that key. Without one it stays open, and
Diagnostics strongly recommends setting it.
2026-10-28 16:28:23 -05:00
iamdoubz 958405651b docs: security review 2 (08 §7) 2026-10-28 16:01:57 -05:00
iamdoubz db244b116a security: recordings, webhooks, Contrack tokens, probe (08 §7 #3 #4 #6 #7)
A phone that only rang no longer gets a coworker's recording, transcript or
summary. The voicemail webhook leaves the transcript out. The saved Contrack
token only goes to its own address. The probe trusts X-Forwarded-For from a
local proxy only.
2026-10-28 16:01:56 -05:00
iamdoubz 1db5aa22dc security: sign-in by email and connecting accounts (08 §7 #1 #2 #5 #8)
Matching by email skips admins and anyone with a connected account, in both
directions. A connected directory account is only swapped by an admin, and
an OpenID connect finishes only in the browser whose session asked.
2026-10-28 16:01:56 -05:00
iamdoubz 8a1f17099d Merge pull request 'Chore/qol' (#7) from chore/qol into main
ci / check (push) Successful in 1m17s
ci / images (push) Successful in 5s
Reviewed-on: #7
2026-10-28 15:18:49 -05:00
iamdoubz a75113b6ac web: report spam from my calls, reports on Blocked numbers
ci / check (push) Successful in 1m24s
ci / images (push) Successful in 25s
ci / check (pull_request) Successful in 1m15s
ci / images (pull_request) Successful in 7s
Users report an outside call that rang them: the spam check's line when it
said medium or high, else a reason they type. Admins block or dismiss each
report on Blocked numbers, and the Dashboard counts them. Migration 0015 adds
spam_reports.
2026-10-28 15:07:05 -05:00
iamdoubz 2bc7d5cde5 web: my reports, the calls made to me
Users get a Reports tab: the admin summary limited to calls that rang or were
answered by them. Answered and talk time are the calls they picked up.
2026-10-28 13:39:33 -05:00
iamdoubz 19ca4ffc91 docs: v1 checklist ticks, DID in the Request-URI
ci / check (push) Successful in 1m12s
ci / images (push) Successful in 30s
The outside install, restores, the week on the owner's server, Portainer
updates and both registries are done. The mobile app is after v1.
2026-10-28 13:28:42 -05:00
iamdoubz 1bc0fe8811 docs: CrowdSec rule for slow SIP scanners
ci / check (push) Successful in 1m21s
ci / images (push) Successful in 22s
The collection's asterisk_bf and asterisk_user_enum hold 5 failures per
address and drain one every 10 s, so a scanner trying every 20 s (two
failures per try: InvalidAccountID, ChallengeResponseFailed) drains as
fast as it fills and is never banned. Seen on a live install: hours of
INVITEs from one address, no decision.

network.md: a local scenario, selfphoneng/asterisk-slow-bf (10 failures,
one drained every 5 min), a table of how it differs from the stock rule,
a whitelist for remote offices and phones, how to read cscli metrics,
and cscli explain with a log line (pointing --file at the acquisition
YAML parses the YAML itself and fails on every line).
2026-10-28 10:56:40 -05:00
iamdoubz add90204c0 Merge pull request 'Feat/ui refresh' (#6) from feat/ui-refresh into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 5s
release / images (release) Successful in 58s
Reviewed-on: #6
2026-10-28 10:22:30 -05:00
iamdoubz 4369dd5ae1 release: version 0.0.11
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m10s
ci / images (pull_request) Successful in 9s
2026-10-28 10:04:42 -05:00
iamdoubz 3d0f091bba web: icons, grouped admin sidebar, Home cards, phone-style softphone
ci / check (push) Successful in 2m5s
ci / images (push) Successful in 3m7s
lucide-react (1.56.0) is the one icon set; it hides icons from screen
readers by default and only the icons used are bundled (+6 kB gzipped).
It replaces the ✕ ☀ ☾ ↑ ↓ ▶ ✓ ← characters and the hand-drawn menu and
phone icons.

The admin sidebar's 21 pages are grouped under Call routing, People,
Features, History and System, each with an icon; the current page gets a
left bar. The My Phone tabs have icons and a blue active state.

Home is a greeting and cards (new shared Panel): My status, Missed calls
with a call-back button, New voicemails, with friendly empty states.

Softphone: a status pill (dot and word), call controls as icon-over-label
buttons that are toggles with aria-pressed (Mute, Hold, Keypad, Transfer,
red Hang up), and a round keypad with the letters under the digits.
2026-10-28 10:00:29 -05:00
iamdoubz 5db4914fa7 web: theme colors from the logo, readable in both themes
Pages used zinc and sky classes with every dark: variant paired by hand,
and several pairs failed WCAG AA: white on the sky-600 buttons (4.1:1),
zinc-500 secondary text on dark backgrounds (3.7-4.1:1), the 10px zinc-400
labels (2.6:1) and the dashboard's green "Connected" (3.3:1).

index.css now defines theme tokens once, light and dark side by side
(page, surface, subtle, line, line-strong, fg, muted, primary and its
soft/line/ink shades, link, focus), exposed to Tailwind with @theme
inline. Pages use those names, so a palette change is one file. Blue
#2563eb from the logo for buttons and links, the logo's navy #0b1120 for
the dark page. Every text pair is at least 4.5:1 in both themes, most 7:1.

Cards and lists sit on the surface color with a light shadow on an
off-white page, at rounded-xl; native controls follow the theme
(color-scheme). Zinc and sky stay only as data colors: status dots and
chart swatches.
2026-10-28 10:00:20 -05:00
iamdoubz fd3f06c167 web: answer calls and navigate on a phone
On an iPhone the incoming call dimmed the screen with no buttons: iOS
zooms into fields under 16px (the 14px login inputs) and stays zoomed,
and the My Phone tabs were wider than the screen, so the centered
dialog was drawn outside what the phone showed.

Fields are 16px on touch screens and buttons at least 44px. The My Phone
tabs and header links fold into a menu button below tablet width. A
ringing call gets its own screen: full screen on a phone, a card on wider
screens, with round Decline and Answer buttons. Esc still declines.
2026-10-28 09:59:39 -05:00
iamdoubz 79bb1fa78b release: version 0.0.10
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 5s
release / images (release) Successful in 2m53s
2026-10-27 18:37:53 -05:00
iamdoubz 11b531d5b4 Merge pull request 'Feat/spam' (#5) from feat/spam into main
ci / check (push) Successful in 1m6s
ci / images (push) Successful in 6s
Reviewed-on: #5
2026-10-27 18:35:05 -05:00
iamdoubz 70083f8f83 api: share a spam check from its start
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 21s
ci / check (pull_request) Successful in 1m9s
ci / images (pull_request) Successful in 33s
SD5 follow-up (14 §4): checks for the same number were shared only once
they reached the IPQualityScore request, after reading the cache and the
pause state. A check that finished in between cost a second request
(CI: "five at once cost one request" failed). Now shared before anything
is awaited.
2026-10-27 18:32:04 -05:00
iamdoubz 6b0c5aa827 web: spam check in call history
ci / check (push) Failing after 1m9s
ci / images (push) Successful in 44s
SD5 (14 §9.5): each incoming call keeps what the spam check knew when it
ended (saved answers and the FTC list only, no new lookup), so the badge
stays in call history after the saved answer expires. Hidden when the
viewer's contacts name the number. New admin guide page, spam-check.md.

Follow-up: the owner's checks with real keys and a real call (14 §9).
2026-10-27 18:16:24 -05:00
iamdoubz e05065599e web: show the spam check while a call rings
SD4 (14 §5): GET /api/me/caller-check for callers ringing my extensions
now, a badge and line in the softphone's ringing dialog and call card,
and the desk phone label in the caller-name lookup when the answer comes
within 1.2 s. Contacts are never checked; low risk shows nothing.

Follow-up: try it on a real call, in the browser and on a desk phone.
2026-10-27 16:51:29 -05:00
iamdoubz 49d529e185 api: download the FTC complaint list
SD3 (14 §2.2): spamFtcSync reads the FTC's Do Not Call complaints a day
at a time, 15 pages a minute, saving its place; failures are kept for
Settings → Spam check and retried the next minute. Download now starts
one. Old complaints are dropped after windowDays.

Follow-up: one real download with the owner's api.data.gov key.
2026-10-27 16:40:14 -05:00
iamdoubz 70f14288ca api: pause spam check when IPQualityScore credits run out
SD2 (14 §3): out of credits pauses IPQualityScore (one warning, one
event), the FTC list answers meanwhile, the dashboard shows a dismissable
spam_quota warning, and spamQuotaCheck resumes it every 6 hours once
credits are back. A newly saved key resumes it at once.

Follow-up: confirm with the owner's real key that the Credit Usage API
costs no credit (TODO in 14 §2.1).
2026-10-27 16:17:42 -05:00
iamdoubz 6f87d6f663 api: spam check settings, lookups and cache
SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
2026-10-27 15:32:31 -05:00
iamdoubz a3dfcb6b14 docs: spam check spec
SD1 (spec 14, decision 0006): IPQualityScore first, then the FTC Do Not
Call complaint list, for outside US callers who aren't in anyone's
contacts. Information only, off by default; credits running out pause
IPQualityScore until its credit usage API says they're back.
2026-10-27 13:51:08 -05:00
iamdoubz dc5965617e release: version 0.0.9
ci / check (push) Successful in 1m4s
ci / images (push) Successful in 5s
release / images (release) Successful in 1m33s
2026-10-27 12:49:05 -05:00
iamdoubz a614319990 Merge pull request 'Feat/mobile' (#4) from feat/mobile into main
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 4s
Reviewed-on: #4
2026-10-27 12:42:08 -05:00
iamdoubz 33d5f1a5f5 web: answer incoming calls from the browser notification
ci / check (push) Successful in 1m34s
ci / images (push) Successful in 27s
ci / check (pull_request) Successful in 1m18s
ci / images (pull_request) Successful in 5s
Owner follow-up to the softphone (spec 05 §5).

- the incoming call notification comes from a service worker
  (public/sw.js) so it carries Answer and Decline buttons (Chromium);
  Answer and a plain click focus the softphone tab, Decline doesn't
- shown whenever the tab isn't focused, not only when it's hidden; kept
  up while it rings and closed once it stops
- the softphone panel says how to allow notifications when they're blocked
2026-10-27 12:35:43 -05:00
iamdoubz 431ada51aa docs: confirm the mobile app id
ci / check (push) Successful in 1m30s
ci / images (push) Successful in 22s
MB3
2026-10-26 20:54:54 -05:00
iamdoubz 7da0611a98 docs: mobile app MB3 as built
MB3: the Expo skeleton and pairing are in the mobile repo (6e4bc2b);
the device test with a real Android phone and iPhone is still open.
2026-10-26 17:45:28 -05:00
iamdoubz 0cdac0bcdc api: pair the mobile app with device tokens
MB1 (spec 13). Decision 0005: our own Android/iPhone app, Expo in the
mobile repo, user portal only, no push in v1.

- mobile_devices (migration 0013): one-time pairing codes from My settings
  (QR or server + code, 10 minutes), traded by the app for an spm_ device
  token that acts as its user and never as an admin
- POST /api/me/mobile-devices/pair (web session only), POST /api/mobile/pair,
  GET/DELETE /api/me/mobile-devices
- signing out everywhere (password change or reset, disabling) removes
  paired phones too
- My settings: Mobile app section with the QR code and paired phones
- mobile/ (its own repo) ignored here
2026-10-26 17:12:53 -05:00
iamdoubz adb6d52ddf release: version 0.0.8
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
release / images (release) Successful in 59s
2026-10-26 09:55:54 -05:00
iamdoubz 7722f33639 Merge pull request 'Feat/sso' (#3) from feat/sso into main
ci / check (push) Successful in 1m20s
ci / images (push) Successful in 4s
Reviewed-on: #3
2026-10-26 09:52:49 -05:00
iamdoubz 97e67e347d auth: security audit fixes, rate limits on every route
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
2026-10-26 09:46:23 -05:00
iamdoubz 19610777a4 auth: directory leavers check, dashboard warning, Samba AD check (S5)
ci / check (push) Successful in 1m22s
ci / images (push) Successful in 22s
Spec 12 S5: job directoryCheck (hourly) looks up every active user with a directory account;
one deleted, disabled there or no longer matched by the user filter is disabled here, sessions
ended. Nothing changes when the directory is unreachable or a run would disable most people;
the last admin with a password is kept; nobody is re-enabled. Dashboard warning
directory_check (system.directoryWarning). The by-id search is a filter object: ldapts string
filters send bytes >= 0x80 as UTF-8, so AD's objectGUID never matched. Live test against a
Samba 4 AD DC: nested-group admin, the disabled bit, objectGUID, a deleted user disabled.
2026-10-26 07:47:12 -05:00
iamdoubz b06335586b auth: sign in with LDAP/LDAPS, directory settings and Test (S4)
Spec 12 S4: the login form checks the SelfPhoneNG password first, then the directory (ldapts,
service-account search, the user's own bind on a second connection). Presets for lldap, AD,
FreeIPA and OpenLDAP; Test with an optional test login. A directory account connects by email,
never makes a user; adminFilter sets the role (applyRole shared with OpenID). One Disconnect
route per kind (oidc, ldap). Live test against lldap 0.6.3 over LDAPS.
2026-10-25 18:39:52 -05:00
iamdoubz 73963ee3a3 auth: OpenID session check, live Keycloak test, sign-in guide (S3)
Spec 12 S3: job oidcCheck refreshes every OpenID session at the provider every 15 minutes and
applies groups and role again; invalid_grant ends the session, an outage doesn't. A live test
makes a throwaway Keycloak realm and signs in through its login form (passed against 26.8.1).
NODE_EXTRA_CA_CERTS for a private CA. Admin guide: docs/admin-guide/sign-in.md.
2026-10-25 15:14:59 -05:00
iamdoubz 06b3a73edc auth: sign in with OpenID Connect, connect and disconnect, sign-in modes (S2)
Spec 12 S2: openid-client 6.8 (decision 0004). Discovery, PKCE + state + nonce, ID token
signatures checked, userinfo always read. Users connect their OpenID account from My account
and sign in either way; no accounts are made; a user without a password is matched once by
verified email. auth.mode mixed and oidc (admins keep a password way in), role from adminGroups,
allowedGroups, signing out at the provider. Settings -> Sign-in with Test. Tested against a fake
provider with RS256 and ES256 keys.
2026-10-25 14:13:27 -05:00
iamdoubz 3a9423af1f auth: sign-in ways, sign-in mode, and the admin who keeps a password (S1)
Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
2026-10-25 13:30:58 -05:00
iamdoubz ba293f8703 spec: single sign-on (12), OpenID Connect and LDAP
Owner decisions 2026-10-25: a user connects their OpenID account and signs in with either it or
their password; the admin picks passwords only, OpenID only (admins keep a password way in), or
mixed. No automatic accounts. OIDC sessions re-checked every 15 min with the refresh token;
signing out also signs out at the provider; password sign-ins keep two-step. LDAP is one more way
to check a password. Decision 0004: openid-client and ldapts. Tasks S1-S5.
2026-10-25 13:09:45 -05:00
iamdoubz 95561f651f release: version 0.0.7
ci / check (push) Successful in 1m4s
ci / images (push) Successful in 5s
release / images (release) Successful in 31s
2026-10-23 16:16:37 -05:00
iamdoubz 53aefaa5d7 Merge pull request 'Feat/contacts' (#2) from feat/contacts into main
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 5s
Reviewed-on: #2
2026-10-23 16:14:19 -05:00
iamdoubz 604e40adf1 contacts: owners' contacts on their desk phones, copy builtin contacts to Contrack (C6)
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 20s
ci / check (pull_request) Successful in 1m4s
ci / images (pull_request) Successful in 5s
Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares
included), then the company's, in both modes. In Contrack mode, contacts from before Contrack
are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import,
skipping what's already there; the builtin rows stay). Tested against a fake and a real
Contrack 2.0.2.
2026-10-23 16:07:19 -05:00
iamdoubz e6a1373f36 contacts: calls on the Contrack timeline, summaries opt-in (C5)
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 20s
Spec 11 §4.4: the contrackLog job puts each connected person's calls with their Contrack
contacts on that contact's timeline once (contrack_call_log, migration 0011), from when they
connected. With summaries on (My settings → Contacts, needs call summaries), the escaped summary
and action items go in, and the first action item becomes a follow-up due the next day. Waits up
to 30 min for a summary; Contrack down is retried, refusals noted once. Tested against a fake and
a real Contrack 2.0.2.
2026-10-23 15:59:53 -05:00
iamdoubz b05361e840 contacts: caller names on desk phones while they ring (C4)
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 22s
Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this
machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the
caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so
forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes
kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk
with baresip: apps/api/test/asterisk/cnam-test.ts.
2026-10-23 15:45:36 -05:00
iamdoubz 692dd59552 contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard
ci / check (push) Successful in 1m0s
ci / images (push) Successful in 32s
Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token
write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings,
books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in
Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack
(2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake
and a real Contrack 2.0.2 (contrack-live.test.ts).
2026-10-23 15:23:49 -05:00
iamdoubz a754974a90 contacts: my book, the company book, vCard/CSV import, sharing with Accept/Deny
C1 (spec 11): /api/me/contacts and /api/contacts (admin), our own vCard 2.1/3.0/4.0 and CSV
readers with a preview, contact_shares (migration 0009) for offers, and contact names in call
history, voicemail, Home, the softphone and the call report email. Admin → Contacts.
2026-10-23 14:53:21 -05:00
iamdoubz 32f4590369 spec: contacts (11), Simple or Contrack, picked at setup
Owner decisions 2026-10-23: vCard import for admins and users; desk-phone caller name while
ringing is its own task (C4); the company is one Contrack account shown to everyone, each user
has their own; sharing offers a copy the colleague accepts or denies; imports skip duplicates.
2026-10-23 14:53:21 -05:00
iamdoubz 20d27232ac Merge pull request 'Feat/call report' (#1) from feat/call-report into main
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 12s
Reviewed-on: #1
2026-10-23 13:59:42 -05:00
iamdoubz 4c006f2388 me: call report email (hourly, daily, weekly), with call summaries
ci / check (push) Successful in 1m14s
ci / images (push) Successful in 40s
ci / check (pull_request) Successful in 59s
ci / images (pull_request) Successful in 4s
My settings → Call report: each user's calls of the last hour, day (at a time) or week (on a
day at a time), company time zone: numbers, status, talk time, recorded, and optionally each
recorded call's summary (10 §3). No calls, no email. Job callReports every minute, 5 min after
the period ends; periods computed in PostgreSQL (DST-safe). Migration 0008_call_reports.
2026-10-23 13:55:47 -05:00
iamdoubz 46cc8c2002 voicemail: search messages by transcript or caller
Filters the already-loaded mailbox in the browser (GET /api/me/voicemail isn't paged).
2026-10-23 13:55:46 -05:00
263 changed files with 54619 additions and 1062 deletions

No files matched your search

+1
View File
@@ -39,3 +39,4 @@
**/*.key
**/*.log
**/*.pid
docs/voip.ms-api
+5
View File
@@ -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
+4
View File
@@ -6,3 +6,7 @@ data/
coverage/
.pnpm-store/
*.pid
# The mobile app is its own repo (13), checked out here beside the PBX.
mobile/
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
docs/voip.ms-api/
+1
View File
@@ -6,3 +6,4 @@ dist/
AGENTS.md
CLAUDE.md
apps/api/src/db/migrations/
mobile/
+4
View File
@@ -27,6 +27,10 @@ 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 |
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information |
## Hard rules
+178
View File
@@ -5,6 +5,184 @@ 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.13 — 2026-10-29
**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose:
`sudo scripts/update.sh v0.0.13`.
Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used
for toll fraud:
- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system
calls an international or premium number (including Caribbean area codes, which are dialed like
a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system
didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the
voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud
alerts**.
- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to
turn it on for the sub-account, with only this server's public IP. A stolen trunk password is
then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)).
- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no
longer has the modules that run commands on the server.
## v0.0.12 — 2026-10-28
**Before you update:** this version changes the database (spam reports); the api backs up first,
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
**Probe key** below).
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
many they answered and missed, talk time, calls and missed calls per day, and their busiest
hours.
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
the spam check flagged the call, its finding is the reason; otherwise the person types one.
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
Dashboard counts them.
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
a connected sign-in, and connecting an OpenID account only finishes in the browser that
started it.
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
its summary in their call report email or on their Contrack timeline.
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
- Changing the Contrack address asks for the company token again, and everyone reconnects
their own Contrack token.
- A directory account can only be swapped for another by an admin.
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
proxy must run on the probe's machine.
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
on Asterisk doing before any version change.
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
([network](docs/admin-guide/network.md#crowdsec)).
## v0.0.11 — 2026-10-28
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
`sudo scripts/update.sh v0.0.11`.
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
into a menu button on narrow screens.
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
throughout.
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
voicemails.
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
## v0.0.10 — 2026-10-27
**Before you update:** this version changes the database (spam check); the api backs up first,
by itself. Portainer: change both tags to `0.0.10` and redeploy. Compose:
`sudo scripts/update.sh v0.0.10`.
- **Spam check** (spec 14, off until you turn it on in Settings → Spam check): when an outside
US caller isn't in the company's or your contacts, the number is looked up and what's known
shows while it rings. The browser phone shows a **Likely spam** or **Possible spam** badge with
a line such as "Fraud score 92 · spammer · VOIP"; desk phones show the caller's name as
**SPAM LIKELY** or **Spam risk** (you can change these) when the answer is quick; call
history keeps the badge. It's information only: calls are never blocked or delayed, and a
failed lookup just shows nothing. Two sources, either or both:
- **IPQualityScore** (1,000 lookups a month free): sends callers' numbers to IPQualityScore,
so turning it on asks you to confirm. Answers are kept 14 days for flagged numbers and 3 for
the rest. When the lookups run out the dashboard says so, checks fall back to the FTC list,
and it carries on by itself when lookups are back.
- **FTC complaint list** (free api.data.gov key): downloaded to the server, so nothing leaves
it. The first download takes about 7 hours; then it fetches what's new every 24 hours.
- See the [admin guide](docs/admin-guide/spam-check.md), including privacy and GDPR.
- Database changes: `spam_lookups`, `ftc_complaints` (the api backs up first).
## v0.0.9 — 2026-10-27
**Before you update:** this version changes the database (paired phones); the api backs up first,
by itself. Portainer: change both tags to `0.0.9` and redeploy. Compose:
`sudo scripts/update.sh v0.0.9`.
- **Answer from the notification**: the softphone's incoming call notification has **Answer** and
**Decline** buttons (Chrome and Edge; Firefox and Safari show it without buttons). It now shows
whenever the softphone tab isn't in front, not only when it's hidden, stays up while the call
rings and goes away once it stops. If notifications are blocked, the softphone panel says how
to allow them.
- **Pair the mobile app** (spec 13): My settings → Mobile app shows a one-time code (QR, or
server + code, good for 10 minutes) that our own Android/iPhone app trades for a device token.
A paired phone acts as its user, never as an admin, and is listed there to remove. Changing or
resetting a password, or disabling a user, removes their paired phones. The app itself is
still in testing and not in the app stores.
- Database changes: `mobile_devices` (the api backs up first).
## 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
+4 -1
View File
@@ -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.13), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today
@@ -19,6 +19,9 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
pickup, conference rooms, paging and intercom, music on hold.
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
whisper, barge) and reports.
- **Spam check** (off until you turn it on): outside callers who aren't contacts are looked up
with IPQualityScore or the FTC complaint list, and flagged in the browser phone, on desk phones
and in call history. Calls are never blocked.
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
database change), backups and restore, background health checks, webhooks and API tokens,
two-step sign-in for admins, limits on outside calls at once.
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.6",
"version": "0.0.13",
"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"
+17 -1
View File
@@ -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';
@@ -21,6 +23,7 @@ import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
import { mobileRoutes } from './routes/mobile.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
@@ -39,6 +42,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 +94,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 +151,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 +160,8 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(settingsRoutes);
app.register(liveRoutes);
app.register(meRoutes);
app.register(mobileRoutes);
app.register(contactRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
app.register(queueRoutes);
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token
// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user.
import { randomInt } from 'node:crypto';
import { and, eq, gt, isNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { mobileDevices, users } from '../db/schema.ts';
import { generateToken, hashToken } from './tokens.ts';
export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/;
/** No 0/O/1/I: people type it from a screen. */
export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const PAIR_CODE_TTL_MS = 10 * 60_000;
export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`;
export function generatePairCode(): string {
let s = '';
for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)];
return s;
}
/** The device's (active) user, or null for an unknown, removed or malformed token. */
export async function getDeviceUser(db: Db, token: string) {
if (!DEVICE_TOKEN_RE.test(token)) return null;
const [row] = await db
.select({ id: mobileDevices.id, user: users })
.from(mobileDevices)
.innerJoin(users, eq(users.id, mobileDevices.userId))
.where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true)));
if (!row) return null;
await db
.update(mobileDevices)
.set({ lastUsedAt: new Date() })
.where(eq(mobileDevices.id, row.id));
return { user: row.user, deviceId: row.id };
}
/**
* Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired,
* or whose user was disabled since.
*/
export async function pairDevice(
db: Db,
code: string,
device: { name: string; platform: 'android' | 'ios' },
) {
const [row] = await db
.select({ id: mobileDevices.id, user: users })
.from(mobileDevices)
.innerJoin(users, eq(users.id, mobileDevices.userId))
.where(
and(
eq(mobileDevices.pairCodeHash, hashToken(code)),
gt(mobileDevices.pairExpiresAt, new Date()),
eq(users.isActive, true),
),
);
if (!row) return null;
const token = generateDeviceToken();
// The code is cleared in the same statement: two apps racing with one code can't both win.
const [won] = await db
.update(mobileDevices)
.set({
...device,
tokenPrefix: token.slice(0, 8),
tokenHash: hashToken(token),
pairCodeHash: null,
pairExpiresAt: null,
})
.where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash)))
.returning({ id: mobileDevices.id });
return won ? { token, deviceId: won.id, user: row.user } : null;
}
+23 -10
View File
@@ -1,9 +1,11 @@
// 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 { getDeviceUser } from './devices.ts';
import { getTokenUser } from './tokens.ts';
export type CurrentUser = typeof users.$inferSelect;
@@ -14,22 +16,28 @@ declare module 'fastify' {
sessionId: string | null;
/** Set when the request came with an API token instead of a session. */
apiTokenId?: string;
/** Set when the request came from a paired phone (13 §2). */
mobileDeviceId?: string;
}
}
/**
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
* Enforces must_change_password outside /api/auth/*.
* Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3),
* or else the session cookie. Enforces must_change_password outside /api/auth/*.
*/
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
if (!t) throw unauthorized();
req.user = t.user;
const raw = auth.slice(7).trim();
const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null;
const t = d ? null : await getTokenUser(req.server.db, raw);
if (!d && !t) throw unauthorized();
// A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings.
req.user = d ? { ...d.user, role: 'user' } : t!.user;
req.sessionId = null;
req.apiTokenId = t.tokenId;
if (t.user.mustChangePassword)
if (d) req.mobileDeviceId = d.deviceId;
else req.apiTokenId = t!.tokenId;
if (req.user.mustChangePassword)
throw forbidden('Change your password first', 'password_change_required');
return;
}
@@ -39,7 +47,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 +55,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 };
+6 -4
View File
@@ -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]);
}
}
+20 -4
View File
@@ -4,7 +4,8 @@ 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 { sessions, users } from '../db/schema.ts';
import { encrypt } from '../crypto/secrets.ts';
import { mobileDevices, sessions, users } from '../db/schema.ts';
export const COOKIE = 'sp_session';
const DAY = 86_400_000;
@@ -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,15 +72,23 @@ 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)));
}
/** Logs a user out everywhere except (optionally) the current session. */
/**
* Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2:
* a password change, a reset, or disabling them).
*/
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId));
await db
.delete(sessions)
.where(
+2
View File
@@ -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),
+236
View File
@@ -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;
}
+26
View File
@@ -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();
+265
View File
@@ -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'}.`;
}
+68
View File
@@ -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 };
}
+62
View File
@@ -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 &lt;wants&gt; a quote &amp; 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: [] });
});
});
+222
View File
@@ -0,0 +1,222 @@
// 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';
import { ON_THE_CALL } from '../services/cdr.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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).
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
for (const r of rows) {
const k = `${r.call.id}|${r.userId}`;
const out = r.role === 'caller' || todo.get(k)?.out === true;
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
todo.set(k, { ...r, out, heard });
}
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 recorded = r.heard && r.call.recordingId !== null;
const wantSummary = r.withSummary && summariesOn && recorded;
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,
},
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;
}
+157
View File
@@ -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/);
});
});
+149
View File
@@ -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;
+21
View File
@@ -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'));
@@ -0,0 +1,19 @@
CREATE TABLE "mobile_devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text,
"platform" text,
"token_prefix" text,
"token_hash" text,
"pair_code_hash" text,
"pair_expires_at" timestamp with time zone,
"last_used_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 "mobile_devices_tokenHash_unique" UNIQUE("token_hash"),
CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"),
CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios'))
);
--> statement-breakpoint
ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id");
@@ -0,0 +1,25 @@
CREATE TABLE "ftc_complaints" (
"id" text PRIMARY KEY NOT NULL,
"number" text NOT NULL,
"reported_at" timestamp with time zone NOT NULL,
"subject" text,
"robocall" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "spam_lookups" (
"number" text PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"risk" text NOT NULL,
"info" jsonb NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
);
--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
@@ -0,0 +1,11 @@
CREATE TABLE "spam_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"number" text NOT NULL,
"reason" text NOT NULL,
"reported_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
);
--> statement-breakpoint
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
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
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,62 @@
"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
},
{
"idx": 13,
"version": "7",
"when": 1793052019332,
"tag": "0013_mobile_devices",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1793127210386,
"tag": "0014_spam_check",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1793212840276,
"tag": "0015_spam_reports",
"breakpoints": true
}
]
}
+164 -2
View File
@@ -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', {
@@ -94,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', {
...timestamps,
});
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
export const mobileDevices = pgTable(
'mobile_devices',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text(),
platform: text(),
tokenPrefix: text(),
tokenHash: text().unique(),
pairCodeHash: text().unique(),
pairExpiresAt: tstz(),
lastUsedAt: tstz(),
...timestamps,
},
(t) => [
index().on(t.userId),
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
],
);
// ───────────── extensions ─────────────
export const outboundClass = pgEnum('outbound_class', [
'internal',
@@ -102,6 +165,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 +192,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,
});
@@ -559,11 +630,59 @@ export const calls = pgTable(
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
talkSeconds: integer().notNull().default(0),
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
/** The spam check of an incoming call's number (14 §6); null = not checked. */
spamRisk: text(),
spamInfo: jsonb(),
...timestamps,
},
(t) => [index().on(t.startedAt.desc())],
);
// ───────────── spam check (14 §7) ─────────────
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
export const spamLookups = pgTable(
'spam_lookups',
{
number: text().primaryKey(),
provider: text().notNull(),
risk: text().notNull(),
info: jsonb().notNull(),
expiresAt: tstz().notNull(),
...timestamps,
},
(t) => [
index().on(t.expiresAt),
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
],
);
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
export const ftcComplaints = pgTable(
'ftc_complaints',
{
id: text().primaryKey(),
number: text().notNull(),
reportedAt: tstz().notNull(),
subject: text(),
robocall: boolean(),
createdAt: createdAt(),
},
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
export const spamReports = pgTable(
'spam_reports',
{
id: id(),
number: text().notNull(),
reason: text().notNull(),
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
...timestamps,
},
(t) => [unique().on(t.number, t.reportedBy)],
);
export const callExtensions = pgTable(
'call_extensions',
{
@@ -612,6 +731,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',
{
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = aor
contact = sip:chicago1.voip.ms:5061;transport=tls
qualify_frequency = 0
qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = endpoint
@@ -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
@@ -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"
}
}
@@ -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
@@ -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
+7 -1
View File
@@ -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)})`]
+3
View File
@@ -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]}`);
+7 -1
View File
@@ -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'
+23 -1
View File
@@ -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
+2
View File
@@ -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',
+7 -1
View File
@@ -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>();
+2
View File
@@ -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 };
+3 -5
View File
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
section(`trunk-${id}`, [
'type = aor',
`contact = sip:${target}`,
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
'qualify_frequency = 60',
]),
section(`trunk-${id}`, [
'type = endpoint',
+6
View File
@@ -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);
+5
View File
@@ -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', {
+55 -2
View File
@@ -2,8 +2,8 @@
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
// `status` = "success" or an error code. The password is in the query string, so the URL is never
// logged or put in an error.
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
// (voip.ms's API docs need a login); check them with the owner's account and fix here.
// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet;
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
import { normalizeNumber } from '@selfphoneng/shared';
export type VoipmsCreds = { username: string; password: string };
@@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
});
}
export type VoipmsCall = {
at: Date;
destination: string;
account: string;
disposition: string;
seconds: number;
total: string;
uniqueid: string;
};
/**
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
*/
export async function getCdr(
baseUrl: string,
creds: VoipmsCreds,
q: { from: string; to: string; account: string },
): Promise<VoipmsCall[]> {
let r: Record<string, unknown>;
try {
r = await voipmsApi(baseUrl, creds, 'getCDR', {
date_from: q.from,
date_to: q.to,
answered: '1',
noanswer: '1',
busy: '1',
failed: '1',
timezone: '0',
account: q.account,
});
} catch (e) {
if (e instanceof VoipmsError && e.status === 'no_cdr') return [];
throw e;
}
return list(r.cdr).flatMap((c) => {
const at = new Date(`${str(c.date).replace(' ', 'T')}Z`);
if (Number.isNaN(at.getTime())) return [];
return [
{
at,
destination: str(c.destination),
account: str(c.account),
disposition: str(c.disposition),
seconds: Number(str(c.seconds)) || 0,
total: str(c.total),
uniqueid: str(c.uniqueid),
},
];
});
}
/**
* The account's balance (getBalance → balance.current_balance, in the account's currency).
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
+87 -5
View File
@@ -3,13 +3,14 @@
// 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';
import {
extensions,
sessions,
spamLookups,
systemEvents,
trunks,
users,
@@ -19,11 +20,20 @@ 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 { createFraudWatch } from './services/fraud.ts';
import { spamQuotaCheck } from './spam/check.ts';
import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -36,7 +46,11 @@ export type JobDeps = {
generator: Pick<Generator, 'apply'>;
voicemail: { scan(ext: string): Promise<void> };
send: SendMail;
log: { info(o: object, m: string): void; error(o: object, m: string): void };
log: {
info(o: object, m: string): void;
warn(o: object, m: string): void;
error(o: object, m: string): void;
};
now?: () => Date;
statfs?: typeof statfs;
};
@@ -64,7 +78,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) {
await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject));
}
/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */
/** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers
* (04 §8 retentionCleanup, 14 §4). */
export async function retentionCleanup(d: JobDeps) {
const recordings = await cleanupRecordings(
d.db,
@@ -107,6 +122,8 @@ export async function retentionCleanup(d: JobDeps) {
await d.db
.delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`));
await cleanupContactShares(d.db);
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
}
@@ -114,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
export function createJobs(d: JobDeps) {
const now = d.now ?? (() => new Date());
const tz = () => getSetting(d.db, 'general.timezone');
const fraud = createFraudWatch({
db: d.db,
voipmsUrl: d.config.VOIPMS_API_URL,
mail: (subject, text) => mailAdmins(d, subject, text),
log: d.log,
now,
});
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
// Retention runs then too (and at start), not at 02:00.
@@ -179,7 +203,8 @@ export function createJobs(d: JobDeps) {
`Public IP changed to ${ip}`,
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' +
'Phones and the provider may need the new IP if they were set up with the old one.',
'Phones and the provider may need the new IP if they were set up with the old one ' +
'(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).',
);
};
@@ -237,7 +262,55 @@ 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 {
spamQuotaCheck: () => spamQuotaCheck(d.db),
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
spamFtcSync: async () => {
const reason = await ftcSync(d.db, { now: now() });
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
},
oidcCheck,
directoryCheck,
daily,
backup,
publicIpWatch,
trunkWatch,
diskWatch,
riskyCallWatch: fraud.riskyCallWatch,
providerWatch: fraud.providerWatch,
callReports,
contactsWarm,
contrackLog,
};
}
/** Starts the jobs; returns a stop function. */
@@ -259,6 +332,15 @@ 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('riskyCallWatch', j.riskyCallWatch);
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
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);
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
run('spamFtcSync', j.spamFtcSync);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+3 -3
View File
@@ -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)
+3 -5
View File
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk(
id: string,
{ waitMs = 1500, qualify = true } = {},
{ waitMs = 1500 } = {},
): Promise<{ live: TrunkLive; rttMs: number | null }> {
if (qualify) {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
}
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id);
+13 -9
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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');
+28 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
// entry checks (03 §6.4). Admin only.
// entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
import { desc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
@@ -9,9 +9,10 @@ import {
formatNumber,
type BlockedDto,
type ListResponse,
type SpamReportDto,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { blockedNumbers } from '../db/schema.ts';
import { blockedNumbers, spamReports, users } from '../db/schema.ts';
import { conflict, notFound } from '../errors.ts';
import { setBlock } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
throw e;
}
await mirror(b.number, true);
await db.delete(spamReports).where(eq(spamReports.number, b.number));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
});
return { ok: true };
});
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
items: (
await db
.select({ r: spamReports, name: users.displayName })
.from(spamReports)
.leftJoin(users, eq(users.id, spamReports.reportedBy))
.orderBy(desc(spamReports.createdAt))
).map(({ r, name }) => ({
id: r.id,
number: r.number,
reason: r.reason,
reportedBy: name,
createdAt: r.createdAt.toISOString(),
})),
nextCursor: null,
}));
app.delete('/api/spam-reports/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
if (!r) throw notFound('Already handled');
return { ok: true };
});
}
+74 -8
View File
@@ -1,20 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm';
// /api/me/spam-reports: Report spam on a call that rang me (05 §4).
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import {
CallFilterSchema,
SpamReportCreateSchema,
formatNumber,
normalizeNumber,
type CallDetailDto,
type CallDto,
type ListResponse,
type MyCallDto,
type SpamCheck,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
import { badRequest, notFound } from '../errors.ts';
import {
blockedNumbers,
callExtensions,
calls,
cdr,
dids,
extensions,
spamReports,
} from '../db/schema.ts';
import { contactNames } from '../contacts/book.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
import { getSetting } from '../services/settings.ts';
const answerer = alias(extensions, 'answerer');
@@ -65,7 +80,8 @@ async function conditions(db: Db, f: Filter): Promise<SQL[]> {
return where;
}
async function page(db: Db, where: SQL[], limit: number) {
/** `viewer`: whose books hide the spam check for a number they name (14 §6). */
async function page(db: Db, where: SQL[], limit: number, viewer: string) {
const rows = await db
.select({ c: calls, did: dids, a: answerer })
.from(calls)
@@ -76,6 +92,12 @@ async function page(db: Db, where: SQL[], limit: number) {
.limit(limit + 1);
const more = rows.length > limit;
const items = rows.slice(0, limit);
const known = await contactNames(
db,
viewer,
items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber),
{ wait: false },
).catch(() => null);
return {
items: items.map(({ c, did, a }): CallDto => ({
id: c.id,
@@ -91,6 +113,10 @@ async function page(db: Db, where: SQL[], limit: number) {
answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null,
talkSeconds: c.talkSeconds,
recordingId: c.recordingId,
spam:
c.spamRisk && known && !known.has(c.fromNumber ?? '')
? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck)
: null,
})),
nextCursor: more ? encodeCursor(items.at(-1)!.c) : null,
};
@@ -111,7 +137,7 @@ export async function callRoutes(app: FastifyInstance) {
{ preHandler: requireAdmin },
async (req): Promise<ListResponse<CallDto>> => {
const f = CallFilterSchema.parse(req.query);
return page(db, await conditions(db, f), f.limit);
return page(db, await conditions(db, f), f.limit, req.user!.id);
},
);
@@ -123,7 +149,7 @@ export async function callRoutes(app: FastifyInstance) {
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
];
// ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger.
const { items } = await page(db, where, 50_000);
const { items } = await page(db, where, 50_000, req.user!.id);
const fmt = new Intl.DateTimeFormat('sv-SE', {
timeZone: tz,
dateStyle: 'short',
@@ -156,7 +182,7 @@ export async function callRoutes(app: FastifyInstance) {
app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise<CallDetailDto> => {
const { id } = z.object({ id: z.uuid() }).parse(req.params);
const { items } = await page(db, [eq(calls.id, id)], 1);
const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id);
const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id));
if (!items[0] || !c) throw notFound('Call not found');
const legs = await db
@@ -189,7 +215,7 @@ export async function callRoutes(app: FastifyInstance) {
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
const where = await conditions(db, { ...f, extensionId: ext.id });
const result = await page(db, where, f.limit);
const result = await page(db, where, f.limit, req.user!.id);
const ids = result.items.map((c) => c.id);
const roles = ids.length
? await db
@@ -203,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
const out = mine.includes('caller');
return {
...c,
// Only rang (a ring group): someone else's conversation (08 §7 #3).
recordingId: mine.some((r) => ON_THE_CALL.includes(r)) ? c.recordingId : null,
myRole: out ? 'out' : 'in',
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
};
@@ -211,4 +239,42 @@ export async function callRoutes(app: FastifyInstance) {
};
},
);
/** The admins see it on Blocked numbers and block or dismiss it. */
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
const body = SpamReportCreateSchema.parse(req.body);
const [c] = await db
.select({ number: calls.fromNumber })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(
and(
eq(calls.id, body.callId),
eq(calls.direction, 'inbound'),
eq(extensions.userId, req.user!.id),
ne(callExtensions.role, 'caller'),
),
)
.limit(1);
if (!c) throw notFound('Not a call from outside that rang you');
let number: string;
try {
number = normalizeNumber(c.number ?? '');
} catch {
throw badRequest("This caller's number was hidden, so it can't be blocked");
}
const [blocked] = await db
.select({ id: blockedNumbers.id })
.from(blockedNumbers)
.where(eq(blockedNumbers.number, number));
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
const [r] = await db
.insert(spamReports)
.values({ number, reason: body.reason, reportedBy: req.user!.id })
.onConflictDoNothing()
.returning();
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
return reply.code(201).send({ ok: true });
});
}
+406
View File
@@ -0,0 +1,406 @@
// 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,
type SpamCheck,
} 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';
import { checkCaller, usNumber } from '../spam/check.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 owner = e?.userId ?? null;
const names = await contactNames(db, owner, [q.data.number], {
wait: false,
}).catch(() => new Map<string, string>());
let name = names.get(q.data.number) ?? '';
// 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s).
// app.log: this route's own log is silent (the token is in the query).
// ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't
// send; IPQualityScore's name for the line follows it when it has one.
if (!name) {
const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200);
if (c && c.risk !== 'low') {
const labels = (await getSetting(db, 'spam')).deskPhoneLabels;
name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`;
}
}
return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name));
},
);
/**
* The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right
* now, so nobody can spend the credits on numbers that never called.
*/
app.get(
'/api/me/caller-check',
{ preHandler: requireAuth },
async (req): Promise<{ check: SpamCheck | null }> => {
const { number } = z.object({ number: z.string().max(30) }).parse(req.query);
const n = usNumber(number);
const mine = (
await db
.select({ number: extensions.number })
.from(extensions)
.where(eq(extensions.userId, req.user!.id))
).map((x) => x.number);
const ringing = app.live.calls
.list()
.some(
(c) =>
c.direction === 'inbound' &&
usNumber(c.from.number) === n &&
c.extensions.some((x) => mine.includes(x)),
);
if (!n || !ringing) return { check: null };
return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) };
},
);
/** 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');
// The saved token only for the saved address: another one gets none (08 §7 #7).
const same = saved?.url === b.url.replace(/\/+$/, '');
const token =
b.companyToken || (same && 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'`),
),
);
+2 -2
View File
@@ -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
View File
@@ -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.
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Pairing the mobile app (13 §2, §4): a one-time code from My settings, traded by the app for a
// device token; the person's paired phones, and removing one.
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import QRCode from 'qrcode';
import { z } from 'zod';
import {
MobilePairSchema,
type ListResponse,
type MobileDeviceDto,
type MobilePairCode,
type MobilePairResult,
} from '@selfphoneng/shared';
import { PAIR_CODE_TTL_MS, generatePairCode, pairDevice } from '../auth/devices.ts';
import { SLOW, requireAuth } from '../auth/guards.ts';
import { hashToken } from '../auth/tokens.ts';
import { mobileDevices } from '../db/schema.ts';
import { forbidden, HttpError, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
const IdParams = z.object({ id: z.uuid() });
export async function mobileRoutes(app: FastifyInstance) {
const db = app.db;
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
app.post(
'/api/me/mobile-devices/pair',
{ preHandler: requireAuth, config: SLOW },
async (req): Promise<MobilePairCode> => {
// Only a person at the web page: no token (a stolen phone can't make itself a spare).
if (!req.sessionId) throw forbidden('Pair a phone from the web page');
const userId = req.user!.id;
await db
.delete(mobileDevices)
.where(and(eq(mobileDevices.userId, userId), isNull(mobileDevices.tokenHash)));
const code = generatePairCode();
const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS);
const [d] = await db
.insert(mobileDevices)
.values({ userId, pairCodeHash: hashToken(code), pairExpiresAt: expiresAt })
.returning({ id: mobileDevices.id });
await audit(db, {
...act(req),
action: 'create',
entityType: 'mobile_device',
entityId: d!.id,
summary: 'Made a code to pair a phone',
});
const server = `https://${app.config.PUBLIC_HOSTNAME}`;
const link = `selfphoneng://pair?${new URLSearchParams({ server, code })}`;
return {
code,
server,
link,
qr: await QRCode.toDataURL(link, { margin: 2, width: 280 }),
expiresAt: expiresAt.toISOString(),
};
},
);
/** Public: the code is the credential. The only time the device token is shown. */
app.post('/api/mobile/pair', { config: SLOW }, async (req): Promise<MobilePairResult> => {
const { code, ...device } = MobilePairSchema.parse(req.body);
const paired = await pairDevice(db, code, device);
if (!paired)
throw new HttpError(
404,
'pair_code_invalid',
'That code is wrong or expired. Make a new one.',
);
await audit(db, {
actorUserId: paired.user.id,
actorIp: req.ip,
action: 'create',
entityType: 'mobile_device',
entityId: paired.deviceId,
summary: `Paired ${device.platform} phone ${device.name}`,
});
return {
token: paired.token,
user: { name: paired.user.displayName, email: paired.user.email },
};
});
app.get(
'/api/me/mobile-devices',
{ preHandler: requireAuth },
async (req): Promise<ListResponse<MobileDeviceDto>> => {
const rows = await db
.select()
.from(mobileDevices)
.where(and(eq(mobileDevices.userId, req.user!.id), isNotNull(mobileDevices.tokenHash)))
.orderBy(mobileDevices.createdAt);
return {
items: rows.map((d) => ({
id: d.id,
name: d.name!,
platform: d.platform as MobileDeviceDto['platform'],
tokenPrefix: d.tokenPrefix!,
createdAt: d.createdAt.toISOString(),
lastUsedAt: d.lastUsedAt?.toISOString() ?? null,
current: d.id === req.mobileDeviceId,
})),
nextCursor: null,
};
},
);
/** Removes one of my phones; the app's Sign out removes itself. */
app.delete('/api/me/mobile-devices/:id', { preHandler: requireAuth }, async (req) => {
const { id } = IdParams.parse(req.params);
const [d] = await db
.delete(mobileDevices)
.where(and(eq(mobileDevices.id, id), eq(mobileDevices.userId, req.user!.id)))
.returning();
if (!d) throw notFound('Phone not found');
await audit(db, {
...act(req),
action: 'delete',
entityType: 'mobile_device',
entityId: id,
summary: `Removed phone ${d.name ?? '(not paired)'}`,
});
return { ok: true };
});
}
+346
View File
@@ -0,0 +1,346 @@
// 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, ne, 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 { COOKIE, createSession, getSessionUser, 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, and
// only in that browser: a planted sp_oidc can't connect someone else's account (08 §7 #8).
const here = req.cookies[COOKIE]
? await getSessionUser(db, req.cookies[COOKIE], false)
: null;
if (here?.sessionId !== connecting)
return fail('expired', 'connect finished in another browser than the one that asked');
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 any connected account yet, by verified email (12 §3.3
// step 2). Never an admin: a provider account with their email isn't them (08 §7 #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),
ne(users.role, 'admin'),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(eq(userIdentities.userId, users.id)),
),
),
)
: [];
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');
}
const r = await connectDirectory(db, req.log, req.user!.id, entry, req.ip);
if (r === 'taken')
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
if (r === 'connected_other')
throw new HttpError(
409,
'connected_other',
'Another directory account is connected. Ask an admin to disconnect it first',
);
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);
});
}
+3 -2
View File
@@ -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)
+12 -2
View File
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
import { badRequest, forbidden, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { sideFiles } from '../ai/calls.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** The recording and, when call transcripts left them, its two side files. */
const removeFiles = async (dir: string, filePath: string) => {
@@ -109,7 +110,10 @@ export async function recordingRoutes(app: FastifyInstance) {
},
);
/** Admins, or a participant: their extension is on the call this recording belongs to. */
/**
* Admins, or who was on the call: their extension made or answered it. Not a phone that only
* rang (a ring group, a queue): that's a coworker's conversation (08 §7 #3).
*/
async function mayHear(user: { id: string; role: string }, recordingId: string) {
if (user.role === 'admin') return;
const [mine] = await db
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
.where(
and(
eq(calls.recordingId, recordingId),
eq(extensions.userId, user.id),
inArray(callExtensions.role, ON_THE_CALL),
),
)
.limit(1);
if (!mine) throw forbidden('Not your call');
}
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more