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.
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
// 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');
|
|
const token =
|
|
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
|
|
try {
|
|
return { ok: true, detail: await contrackCheck(b.url, token) };
|
|
} catch (err) {
|
|
return { ok: false, error: (err as Error).message };
|
|
}
|
|
},
|
|
);
|
|
|
|
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
|
|
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
|
|
const s = ContactShareSchema.parse(req.body);
|
|
const me = req.user!.id;
|
|
const picked = (await readBooks(db, me)).items.filter(
|
|
(c) => c.book === 'mine' && s.contactIds.includes(c.id),
|
|
);
|
|
if (picked.length !== new Set(s.contactIds).size) throw notFound('Contact not found');
|
|
const to = s.everyone
|
|
? await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(and(eq(users.isActive, true), ne(users.id, me)))
|
|
: await db
|
|
.select({ id: users.id })
|
|
.from(extensions)
|
|
.innerJoin(users, eq(users.id, extensions.userId))
|
|
.where(
|
|
and(
|
|
inArray(extensions.number, s.extensions),
|
|
eq(users.isActive, true),
|
|
ne(users.id, me),
|
|
),
|
|
);
|
|
if (!to.length) throw badRequest('Nobody to share with');
|
|
await db.transaction(async (tx) => {
|
|
// The same contact offered again replaces the pending offer.
|
|
await tx.delete(contactShares).where(
|
|
and(
|
|
eq(contactShares.status, 'pending'),
|
|
inArray(
|
|
contactShares.toUserId,
|
|
to.map((u) => u.id),
|
|
),
|
|
inArray(
|
|
contactShares.sourceId,
|
|
picked.map((c) => c.id),
|
|
),
|
|
),
|
|
);
|
|
await tx.insert(contactShares).values(
|
|
to.flatMap((u) =>
|
|
picked.map(({ id, name, company, email, numbers, notes }) => ({
|
|
fromUserId: me,
|
|
toUserId: u.id,
|
|
sourceId: id,
|
|
contact: { name, company, email, numbers, notes },
|
|
})),
|
|
),
|
|
);
|
|
});
|
|
return { offers: picked.length * to.length, people: to.length };
|
|
});
|
|
|
|
app.get(
|
|
'/api/me/contacts/shares',
|
|
{ preHandler: requireAuth },
|
|
async (req): Promise<ListResponse<ContactShareDto>> => {
|
|
const rows = await db
|
|
.select({ s: contactShares, fromName: users.displayName })
|
|
.from(contactShares)
|
|
.leftJoin(users, eq(users.id, contactShares.fromUserId))
|
|
.where(and(eq(contactShares.toUserId, req.user!.id), eq(contactShares.status, 'pending')))
|
|
.orderBy(desc(contactShares.createdAt));
|
|
return {
|
|
items: rows.map(({ s, fromName }) => ({
|
|
id: s.id,
|
|
from: s.fromUserId && fromName ? { userId: s.fromUserId, name: fromName } : null,
|
|
contact: s.contact as ContactData,
|
|
createdAt: s.createdAt.toISOString(),
|
|
})),
|
|
nextCursor: null,
|
|
};
|
|
},
|
|
);
|
|
|
|
for (const decision of ['accept', 'deny'] as const)
|
|
app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => {
|
|
const { ids } = ContactShareDecisionSchema.parse(req.body);
|
|
const me = req.user!.id;
|
|
const mineAndPending = and(
|
|
eq(contactShares.toUserId, me),
|
|
eq(contactShares.status, 'pending'),
|
|
inArray(contactShares.id, ids),
|
|
);
|
|
const offers = await db.select().from(contactShares).where(mineAndPending);
|
|
// Added first, then marked: Contrack being down (or not connected) leaves the offers waiting.
|
|
const r =
|
|
decision === 'accept'
|
|
? await addToBook(
|
|
db,
|
|
me,
|
|
offers.map((o) => o.contact as ContactData),
|
|
true,
|
|
)
|
|
: null;
|
|
await db
|
|
.update(contactShares)
|
|
.set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
|
|
.where(
|
|
and(
|
|
mineAndPending,
|
|
inArray(
|
|
contactShares.id,
|
|
offers.map((o) => o.id),
|
|
),
|
|
),
|
|
);
|
|
return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length };
|
|
});
|
|
}
|
|
|
|
/** Decided offers go after 30 days (11 §2.1); retentionCleanup calls it. */
|
|
export const cleanupContactShares = (db: FastifyInstance['db']) =>
|
|
db
|
|
.delete(contactShares)
|
|
.where(
|
|
and(
|
|
ne(contactShares.status, 'pending'),
|
|
lt(contactShares.decidedAt, sql`now() - interval '30 days'`),
|
|
),
|
|
);
|