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.
This commit is contained in:
1 parent
a3dfcb6b14
commit
6f87d6f663
22 files changed
+5907
-23
No files matched your search
@@ -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");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
||||
"when": 1793052019332,
|
||||
"tag": "0013_mobile_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1793127210386,
|
||||
"tag": "0014_spam_check",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -630,11 +630,46 @@ 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)],
|
||||
);
|
||||
|
||||
export const callExtensions = pgTable(
|
||||
'call_extensions',
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
|
||||
import {
|
||||
extensions,
|
||||
sessions,
|
||||
spamLookups,
|
||||
systemEvents,
|
||||
trunks,
|
||||
users,
|
||||
@@ -70,7 +71,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,
|
||||
@@ -113,6 +115,7 @@ 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');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
OidcInputSchema,
|
||||
SettingsSchemas,
|
||||
SmtpInputSchema,
|
||||
SpamInputSchema,
|
||||
type AiTestResult,
|
||||
type AuthMode,
|
||||
type Destination,
|
||||
@@ -19,13 +20,16 @@ import {
|
||||
type OidcTestResult,
|
||||
type SettingKey,
|
||||
type SettingsDto,
|
||||
type SpamInput,
|
||||
type SpamSettings,
|
||||
type SpamTestResult,
|
||||
type Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists } from '../services/destinations.ts';
|
||||
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
|
||||
@@ -39,6 +43,9 @@ import { forgetBook } from '../contacts/contrack.ts';
|
||||
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
|
||||
import { directoryUserFor } from '../sso/directory.ts';
|
||||
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
|
||||
import { ftcTest } from '../spam/ftc.ts';
|
||||
import { SpamError } from '../spam/http.ts';
|
||||
import { ipqsCredits } from '../spam/ipqs.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -52,6 +59,7 @@ const READ_ONLY = new Set<string>([
|
||||
'system.restartRequired',
|
||||
'system.transportHash',
|
||||
'system.directoryWarning',
|
||||
'spam.state',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
]);
|
||||
@@ -62,10 +70,20 @@ async function toDto(db: Db): Promise<SettingsDto> {
|
||||
'contacts.contrack': ctk,
|
||||
'auth.oidc': oidc,
|
||||
'auth.ldap': ldap,
|
||||
spam,
|
||||
...rest
|
||||
} = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
spam: {
|
||||
...spam,
|
||||
ipqs: spam.ipqs && { hasKey: true },
|
||||
ftc: spam.ftc && {
|
||||
hasKey: true,
|
||||
syncHours: spam.ftc.syncHours,
|
||||
windowDays: spam.ftc.windowDays,
|
||||
},
|
||||
},
|
||||
'auth.ldap': ldap && {
|
||||
enabled: ldap.enabled,
|
||||
preset: ldap.preset,
|
||||
@@ -209,6 +227,28 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
trustEmail: input.trustEmail,
|
||||
};
|
||||
}
|
||||
/** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */
|
||||
async function spamFromInput(input: SpamInput): Promise<SpamSettings> {
|
||||
const v = SpamInputSchema.parse(input);
|
||||
const old = await getSetting(db, 'spam');
|
||||
const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => {
|
||||
if (typed) return encrypt(typed);
|
||||
if (stored) return stored;
|
||||
throw badRequest(`Enter the ${name} key`);
|
||||
};
|
||||
const ipqs = v.ipqs && {
|
||||
keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'),
|
||||
};
|
||||
const ftc = v.ftc && {
|
||||
keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'),
|
||||
syncHours: v.ftc.syncHours,
|
||||
windowDays: v.ftc.windowDays,
|
||||
};
|
||||
if (v.enabled && !ipqs && !ftc)
|
||||
throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on');
|
||||
return { ...v, ipqs, ftc };
|
||||
}
|
||||
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/settings', () => toDto(db));
|
||||
@@ -258,6 +298,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
forgetBook(null);
|
||||
} else if (key === 'auth.ldap') {
|
||||
values[key] = await ldapFromInput(raw);
|
||||
} else if (key === 'spam') {
|
||||
values[key] = await spamFromInput(raw as SpamInput);
|
||||
} else if (key === 'auth.oidc') {
|
||||
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
@@ -403,6 +445,32 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore
|
||||
* answers with its credits left, which spends none.
|
||||
*/
|
||||
for (const provider of ['ipqs', 'ftc'] as const)
|
||||
app.post(
|
||||
`/api/settings/spam/${provider}/test`,
|
||||
{ config: SLOW },
|
||||
async (req): Promise<SpamTestResult> => {
|
||||
const { key } = z
|
||||
.object({ key: z.string().trim().max(200).optional() })
|
||||
.parse(req.body ?? {});
|
||||
const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc;
|
||||
const k = key || (keyEnc && decrypt(keyEnc));
|
||||
if (!k) return { ok: false, error: 'Enter the key first' };
|
||||
try {
|
||||
if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) };
|
||||
const credits = await ipqsCredits(k);
|
||||
return { ok: true, detail: `Works: ${credits} credits left this billing period.` };
|
||||
} catch (err) {
|
||||
if (err instanceof SpamError) return { ok: false, error: err.message };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
|
||||
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
|
||||
const ip = await detectPublicIp();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The spam check (14 §5): the one way to ask what's known about a caller's number. Information
|
||||
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
|
||||
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { IpqsInfo, SpamCheck, SpamRisk, SpamSettings } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { spamLookups } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ftcLookup } from './ftc.ts';
|
||||
import { SpamError } from './http.ts';
|
||||
import { ipqsLookup } from './ipqs.ts';
|
||||
|
||||
export type SpamDeps = { db: Db; log: Pick<FastifyBaseLogger, 'warn'> };
|
||||
|
||||
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
|
||||
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
|
||||
|
||||
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
|
||||
const pending = new Map<string, Promise<SpamCheck | null>>();
|
||||
|
||||
function warn(d: SpamDeps, provider: string, err: unknown) {
|
||||
if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed');
|
||||
else d.log.warn({ provider, err }, 'spam check failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`:
|
||||
* the cache and the local FTC copy, never a request out (call history, 14 §6).
|
||||
*/
|
||||
export async function checkNumber(
|
||||
d: SpamDeps,
|
||||
number: string,
|
||||
opts: { cachedOnly?: boolean } = {},
|
||||
): Promise<SpamCheck | null> {
|
||||
if (!isCheckable(number)) return null;
|
||||
const s = await getSetting(d.db, 'spam');
|
||||
if (!s.enabled) return null;
|
||||
if (s.ipqs) {
|
||||
const [hit] = await d.db
|
||||
.select()
|
||||
.from(spamLookups)
|
||||
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
|
||||
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
|
||||
if (!opts.cachedOnly) {
|
||||
let p = pending.get(number);
|
||||
if (!p) {
|
||||
p = askIpqs(d, s, s.ipqs.keyEnc, number).finally(() => pending.delete(number));
|
||||
pending.set(number, p);
|
||||
}
|
||||
const answer = await p;
|
||||
if (answer) return answer;
|
||||
}
|
||||
}
|
||||
if (!s.ftc) return null;
|
||||
try {
|
||||
return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) };
|
||||
} catch (err) {
|
||||
warn(d, 'ftc', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function askIpqs(
|
||||
d: SpamDeps,
|
||||
s: SpamSettings,
|
||||
keyEnc: string,
|
||||
number: string,
|
||||
): Promise<SpamCheck | null> {
|
||||
try {
|
||||
const a = await ipqsLookup(decrypt(keyEnc), number);
|
||||
if (a.kind === 'exhausted') {
|
||||
// TODO(spec): SD2 pauses IPQualityScore here (14 §3); until then each check tries again.
|
||||
d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up');
|
||||
return null;
|
||||
}
|
||||
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
|
||||
const expiresAt = sql`now() + make_interval(days => ${days})`;
|
||||
await d.db
|
||||
.insert(spamLookups)
|
||||
.values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt })
|
||||
.onConflictDoUpdate({
|
||||
target: spamLookups.number,
|
||||
set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() },
|
||||
});
|
||||
return { risk: a.risk, provider: 'ipqs', info: a.info };
|
||||
} catch (err) {
|
||||
warn(d, 'ipqs', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
|
||||
// out. The copy is synced by spamFtcSync (SD3).
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { SpamError, getJson } from './http.ts';
|
||||
|
||||
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
|
||||
|
||||
/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */
|
||||
export const ftcRisk = (complaints: number): SpamRisk =>
|
||||
complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low';
|
||||
|
||||
/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */
|
||||
export async function ftcLookup(
|
||||
db: Db,
|
||||
number: string,
|
||||
windowDays: number,
|
||||
): Promise<{ risk: SpamRisk; info: FtcInfo }> {
|
||||
const r = await db.execute<{
|
||||
complaints: number;
|
||||
robocalls: number;
|
||||
last: Date | string | null;
|
||||
subject: string | null;
|
||||
}>(sql`
|
||||
select count(*)::int as complaints,
|
||||
(count(*) filter (where robocall))::int as robocalls,
|
||||
max(reported_at) as last,
|
||||
mode() within group (order by subject) as subject
|
||||
from ftc_complaints
|
||||
where number = ${number}
|
||||
and reported_at > now() - make_interval(days => ${windowDays})`);
|
||||
const row = r.rows[0]!;
|
||||
const info: FtcInfo = {
|
||||
complaints: row.complaints,
|
||||
robocalls: row.robocalls,
|
||||
lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(),
|
||||
subject: row.subject,
|
||||
};
|
||||
return { risk: ftcRisk(info.complaints), info };
|
||||
}
|
||||
|
||||
/** Settings → Spam check → Test: one request with the api.data.gov key. */
|
||||
export async function ftcTest(key: string): Promise<string> {
|
||||
try {
|
||||
await getJson(`${FTC_API}?items_per_page=1`, { 'X-Api-Key': key });
|
||||
} catch (err) {
|
||||
const m = (err as Error).message;
|
||||
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
|
||||
if (m === 'HTTP 429') throw new SpamError('api.data.gov says the key is over its hourly limit');
|
||||
throw err;
|
||||
}
|
||||
return 'The FTC answered.';
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL
|
||||
// or fetch's own error, which can quote it (IPQualityScore's key is in the path).
|
||||
|
||||
/** A failure whose message is safe to log and show. */
|
||||
export class SpamError extends Error {}
|
||||
|
||||
export async function getJson(
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) });
|
||||
} catch (err) {
|
||||
throw new SpamError(
|
||||
(err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect",
|
||||
);
|
||||
}
|
||||
if (!r.ok) throw new SpamError(`HTTP ${r.status}`);
|
||||
try {
|
||||
return (await r.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new SpamError('the answer was not JSON');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts.
|
||||
import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared';
|
||||
import { SpamError, getJson } from './http.ts';
|
||||
|
||||
const BASE = 'https://www.ipqualityscore.com/api/json';
|
||||
export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' };
|
||||
|
||||
const get = (path: string) => getJson(`${BASE}/${path}`);
|
||||
|
||||
const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null);
|
||||
const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg);
|
||||
/** The provider's own message, cut short and without the key in case it quotes it. */
|
||||
const said = (msg: unknown, key: string) =>
|
||||
new SpamError(
|
||||
typeof msg === 'string' && msg
|
||||
? msg.replaceAll(key, '***').slice(0, 200)
|
||||
: 'the request failed',
|
||||
);
|
||||
|
||||
/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */
|
||||
export function ipqsRisk(a: {
|
||||
fraudScore: number;
|
||||
spammer: boolean;
|
||||
recentAbuse: boolean;
|
||||
risky: boolean;
|
||||
}): SpamRisk {
|
||||
if (a.spammer || a.fraudScore >= 85) return 'high';
|
||||
if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/** One phone lookup: `number` is 1NXXNXXXXXX. */
|
||||
export async function ipqsLookup(key: string, number: string): Promise<IpqsAnswer> {
|
||||
const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`);
|
||||
if (j.success !== true) {
|
||||
if (used(j.message)) return { kind: 'exhausted' };
|
||||
throw said(j.message, key);
|
||||
}
|
||||
const info: IpqsInfo = {
|
||||
fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0,
|
||||
spammer: j.spammer === true,
|
||||
recentAbuse: j.recent_abuse === true,
|
||||
lineType: str(j.line_type),
|
||||
carrier: str(j.carrier),
|
||||
name: str(j.name),
|
||||
city: str(j.city),
|
||||
region: str(j.region),
|
||||
};
|
||||
return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info };
|
||||
}
|
||||
|
||||
/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */
|
||||
export async function ipqsCredits(key: string): Promise<number> {
|
||||
const j = await get(`account/${encodeURIComponent(key)}`);
|
||||
if (j.success === false) throw said(j.message, key);
|
||||
const credits = Number(j.credits);
|
||||
if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count');
|
||||
return credits;
|
||||
}
|
||||
@@ -94,6 +94,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
answered_by_extension_id: 'uuid|YES|',
|
||||
talk_seconds: 'integer|NO|0',
|
||||
recording_id: 'uuid|YES|',
|
||||
spam_risk: 'text|YES|',
|
||||
spam_info: 'jsonb|YES|',
|
||||
...ts,
|
||||
},
|
||||
audit_log: {
|
||||
@@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 45 tables', async () => {
|
||||
it('creates all 47 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 45 });
|
||||
expect(r.rows[0]).toEqual({ n: 47 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SD1: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { auditLog, ftcComplaints, spamLookups } from '../src/db/schema.ts';
|
||||
import { checkNumber } from '../src/spam/check.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const IPQS_KEY = 'ipqs-secret-key-123';
|
||||
const FTC_KEY = 'datagov-secret-key-456';
|
||||
const SPAMMER = '13125550001';
|
||||
const SHADY = '13125550002';
|
||||
const CLEAN = '13125550003';
|
||||
const REPORTED = '13125550004';
|
||||
|
||||
/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */
|
||||
const ANSWERS: Record<string, Record<string, unknown>> = {
|
||||
[SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' },
|
||||
[SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' },
|
||||
[CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' },
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('spam check', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let admin: Req;
|
||||
const urls: string[] = [];
|
||||
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
|
||||
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' = 'ok';
|
||||
const log = { warn: vi.fn() };
|
||||
const d = () => ({ db: t.db, log });
|
||||
|
||||
const settings = (s: Partial<SpamSettings>) =>
|
||||
setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s });
|
||||
// Getters: the secret key is only set once createTestApp has run.
|
||||
const keys = {
|
||||
get withIpqs() {
|
||||
return { ipqs: { keyEnc: encrypt(IPQS_KEY) } };
|
||||
},
|
||||
get withFtc() {
|
||||
return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } };
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
admin = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const day = (n: number) => sql`now() - make_interval(days => ${n})`;
|
||||
await t.db.insert(ftcComplaints).values([
|
||||
{ id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true },
|
||||
{ id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true },
|
||||
{ id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false },
|
||||
{ id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true },
|
||||
{ id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null },
|
||||
]);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
beforeEach(() => {
|
||||
urls.length = 0;
|
||||
log.warn.mockClear();
|
||||
ipqs = 'ok';
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
urls.push(url);
|
||||
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
|
||||
if (url.startsWith('https://api.ftc.gov/')) {
|
||||
const key = (init?.headers as Record<string, string>)['X-Api-Key'];
|
||||
return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403);
|
||||
}
|
||||
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
|
||||
if (account)
|
||||
return account[1] === IPQS_KEY
|
||||
? json({ success: true, credits: 912, usage: 88 })
|
||||
: json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` });
|
||||
const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url);
|
||||
if (!phone) return json({}, 404);
|
||||
if (ipqs === 'down') return json({}, 503);
|
||||
if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
|
||||
if (ipqs === 'exhausted')
|
||||
return json({
|
||||
success: false,
|
||||
message: 'You have insufficient credits to make this query.',
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await t.db.delete(spamLookups);
|
||||
});
|
||||
|
||||
it('checks nothing while off, and only US numbers', async () => {
|
||||
await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs });
|
||||
expect(await checkNumber(d(), SPAMMER)).toBeNull();
|
||||
await settings(keys.withIpqs);
|
||||
for (const n of ['442079460958', '911', '101', '13125550', '10005550001'])
|
||||
expect(await checkNumber(d(), n)).toBeNull();
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it('IPQualityScore: risk from its flags and score, only the kept fields', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
expect(await checkNumber(d(), SPAMMER)).toEqual({
|
||||
risk: 'high',
|
||||
provider: 'ipqs',
|
||||
info: {
|
||||
fraudScore: 40,
|
||||
spammer: true,
|
||||
recentAbuse: false,
|
||||
lineType: 'VOIP',
|
||||
carrier: 'Acme Tel',
|
||||
name: null,
|
||||
city: null,
|
||||
region: null,
|
||||
},
|
||||
});
|
||||
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
|
||||
expect(await checkNumber(d(), CLEAN)).toMatchObject({
|
||||
risk: 'low',
|
||||
info: { carrier: 'Verizon' },
|
||||
});
|
||||
expect(urls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => {
|
||||
await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } });
|
||||
await checkNumber(d(), SPAMMER);
|
||||
await checkNumber(d(), CLEAN);
|
||||
await checkNumber(d(), SPAMMER);
|
||||
await checkNumber(d(), CLEAN);
|
||||
expect(urls).toHaveLength(2);
|
||||
const days = await t.db.execute<{ number: string; days: number }>(
|
||||
sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`,
|
||||
);
|
||||
expect(days.rows).toEqual([
|
||||
{ number: SPAMMER, days: 14 },
|
||||
{ number: CLEAN, days: 3 },
|
||||
]);
|
||||
// An expired answer is asked again.
|
||||
await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` });
|
||||
await checkNumber(d(), CLEAN);
|
||||
expect(urls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('a check under way is shared: five at once cost one request', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER)));
|
||||
expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high']));
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
for (const mode of ['down', 'slow', 'exhausted'] as const) {
|
||||
ipqs = mode;
|
||||
expect(await checkNumber(d(), REPORTED)).toEqual({
|
||||
risk: 'high',
|
||||
provider: 'ftc',
|
||||
info: {
|
||||
complaints: 3,
|
||||
robocalls: 2,
|
||||
lastReportedAt: expect.any(String),
|
||||
subject: 'Warranties',
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(log.warn).toHaveBeenCalledTimes(3);
|
||||
expect(log.warn.mock.calls.map((c) => c[0])).toEqual([
|
||||
{ provider: 'ipqs', reason: 'HTTP 503' },
|
||||
{ provider: 'ipqs', reason: 'no answer in 4 s' },
|
||||
{ provider: 'ipqs' },
|
||||
]);
|
||||
expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY);
|
||||
expect(await t.db.select().from(spamLookups)).toEqual([]);
|
||||
});
|
||||
|
||||
it('without IPQualityScore the FTC list answers; without either, nothing', async () => {
|
||||
ipqs = 'down';
|
||||
await settings(keys.withFtc);
|
||||
expect((await checkNumber(d(), SHADY))?.info).toEqual({
|
||||
complaints: 1,
|
||||
robocalls: 0,
|
||||
lastReportedAt: expect.any(String),
|
||||
subject: null,
|
||||
});
|
||||
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
|
||||
expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
|
||||
expect(urls).toEqual([]);
|
||||
await settings({});
|
||||
expect(await checkNumber(d(), REPORTED)).toBeNull();
|
||||
});
|
||||
|
||||
it('cachedOnly never asks IPQualityScore', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
|
||||
await checkNumber(d(), SPAMMER);
|
||||
expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs');
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('Settings → Spam check', () => {
|
||||
const body = (s: Record<string, unknown> = {}) => ({
|
||||
spam: {
|
||||
enabled: true,
|
||||
ipqs: { key: IPQS_KEY },
|
||||
ftc: null,
|
||||
cache: { flaggedDays: 14, cleanDays: 3 },
|
||||
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
|
||||
...s,
|
||||
},
|
||||
});
|
||||
|
||||
it('keys are write-only, and empty keeps the saved one', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const r = await admin('PATCH', '/api/settings', body());
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().spam).toEqual({
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ipqs: { hasKey: true },
|
||||
});
|
||||
expect(r.body).not.toContain(IPQS_KEY);
|
||||
const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc;
|
||||
expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved);
|
||||
const [a] = await t.db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.summary, 'Changed settings: spam'));
|
||||
expect(JSON.stringify(a!.diff)).not.toContain(saved);
|
||||
});
|
||||
|
||||
it('refuses a provider without a key, nothing to check with, and odd labels', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const no = async (b: unknown, error: RegExp) => {
|
||||
const r = await admin('PATCH', '/api/settings', b);
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.body).toMatch(error);
|
||||
};
|
||||
await no(body({ ipqs: {} }), /Enter the IPQualityScore key/);
|
||||
await no(
|
||||
body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }),
|
||||
/Enter the api.data.gov key/,
|
||||
);
|
||||
await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/);
|
||||
await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/);
|
||||
await no(
|
||||
body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }),
|
||||
/syncHours|too small|>=1/i,
|
||||
);
|
||||
await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/);
|
||||
// Off with no provider is fine.
|
||||
expect(
|
||||
(await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it('Test uses the typed key, else the saved one, and never shows it', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const test = async (provider: string, key?: string) =>
|
||||
(await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json();
|
||||
expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' });
|
||||
expect(await test('ipqs', IPQS_KEY)).toEqual({
|
||||
ok: true,
|
||||
detail: 'Works: 912 credits left this billing period.',
|
||||
});
|
||||
const bad = await test('ipqs', 'wrong-key-789');
|
||||
expect(bad.ok).toBe(false);
|
||||
expect(JSON.stringify(bad)).not.toContain('wrong-key-789');
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
expect((await test('ipqs')).ok).toBe(true);
|
||||
expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' });
|
||||
expect(await test('ftc', 'nope')).toEqual({
|
||||
ok: false,
|
||||
error: 'api.data.gov refused the key',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {
|
||||
LdapInput,
|
||||
LdapTestResult,
|
||||
SpamTestResult,
|
||||
AuthMethods,
|
||||
OidcInput,
|
||||
OidcTestResult,
|
||||
@@ -259,6 +260,9 @@ export const settingsApi = {
|
||||
/** 12 §4.1 */
|
||||
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
|
||||
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
|
||||
/** 14 §7.1: the typed key, else the saved one. */
|
||||
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
|
||||
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
|
||||
};
|
||||
|
||||
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups,
|
||||
// Webhooks, API tokens; AI (spec 10).
|
||||
// Webhooks, API tokens; AI (spec 10); Spam check (spec 14).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
@@ -43,6 +43,7 @@ const TABS = [
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'contacts', label: 'Contacts' },
|
||||
{ id: 'sign-in', label: 'Sign-in' },
|
||||
{ id: 'spam', label: 'Spam check' },
|
||||
] as const;
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -79,6 +80,7 @@ export function SettingsPage() {
|
||||
{tab === 'tokens' && <TokensTab />}
|
||||
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
|
||||
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
|
||||
{settings.data && tab === 'spam' && <SpamTab s={settings.data} />}
|
||||
{settings.data && tab === 'sign-in' && (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SignInTab s={settings.data} />
|
||||
@@ -1373,6 +1375,251 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Test for one spam check provider (14 §7.1): the typed key, else the saved one. */
|
||||
function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: string }) {
|
||||
const test = useMutation({
|
||||
mutationFn: () => settingsApi.testSpam(provider, keyText || undefined),
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending}>
|
||||
Test
|
||||
</Button>
|
||||
{test.isError && <Alert>{test.error.message}</Alert>}
|
||||
{test.data && (
|
||||
<span
|
||||
role="status"
|
||||
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
|
||||
>
|
||||
{test.data.ok ? test.data.detail : test.data.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
|
||||
|
||||
/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
|
||||
function SpamTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const sp = s.spam;
|
||||
const st = s['spam.state'];
|
||||
const [v, setV] = useState({
|
||||
enabled: sp.enabled,
|
||||
useIpqs: sp.ipqs !== null,
|
||||
ipqsKey: '',
|
||||
useFtc: sp.ftc !== null,
|
||||
ftcKey: '',
|
||||
syncHours: String(sp.ftc?.syncHours ?? 24),
|
||||
windowDays: String(sp.ftc?.windowDays ?? 30),
|
||||
flaggedDays: String(sp.cache.flaggedDays),
|
||||
cleanDays: String(sp.cache.cleanDays),
|
||||
high: sp.deskPhoneLabels.high,
|
||||
medium: sp.deskPhoneLabels.medium,
|
||||
});
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const text = (k: keyof typeof v) => (e: { target: { value: string } }) =>
|
||||
setV({ ...v, [k]: e.target.value });
|
||||
const flag = (k: 'enabled' | 'useIpqs' | 'useFtc') => (e: { target: { checked: boolean } }) =>
|
||||
setV({ ...v, [k]: e.target.checked });
|
||||
const submit = () => {
|
||||
setConfirming(false);
|
||||
save.mutate(
|
||||
{
|
||||
spam: {
|
||||
enabled: v.enabled,
|
||||
ipqs: v.useIpqs ? { key: v.ipqsKey || undefined } : null,
|
||||
ftc: v.useFtc
|
||||
? {
|
||||
key: v.ftcKey || undefined,
|
||||
syncHours: Number(v.syncHours),
|
||||
windowDays: Number(v.windowDays),
|
||||
}
|
||||
: null,
|
||||
cache: { flaggedDays: Number(v.flaggedDays), cleanDays: Number(v.cleanDays) },
|
||||
deskPhoneLabels: { high: v.high, medium: v.medium },
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => setV((x) => ({ ...x, ipqsKey: '', ftcKey: '' })) },
|
||||
);
|
||||
};
|
||||
// 14 §8: sending callers' numbers out is confirmed once, when IPQualityScore starts being used.
|
||||
const sendsOut = v.enabled && v.useIpqs && !(sp.enabled && sp.ipqs);
|
||||
const num = (k: 'syncHours' | 'windowDays' | 'flaggedDays' | 'cleanDays', label: string) => (
|
||||
<Field label={label}>
|
||||
{(a) => (
|
||||
<input {...a} inputMode="numeric" className={inputClass} value={v[k]} onChange={text(k)} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
const keyField = (k: 'ipqsKey' | 'ftcKey', label: string, saved: boolean, hint: string) => (
|
||||
<Field label={label} hint={saved ? 'Saved. Paste a new one to replace it.' : hint}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={saved ? '••••••••' : undefined}
|
||||
className={inputClass}
|
||||
value={v[k]}
|
||||
onChange={text(k)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (sendsOut) setConfirming(true);
|
||||
else submit();
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-500">
|
||||
When an outside caller from the US isn’t in anyone’s contacts, look the number
|
||||
up and show what’s known about them while it rings. Information only: calls are never
|
||||
blocked or sent elsewhere. IPQualityScore answers first when it’s set up; the FTC list
|
||||
answers when it isn’t, or can’t.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={v.enabled} onChange={flag('enabled')} className="size-4" />
|
||||
Spam check on
|
||||
</label>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">IPQualityScore</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={v.useIpqs}
|
||||
onChange={flag('useIpqs')}
|
||||
className="size-4"
|
||||
/>
|
||||
Use IPQualityScore: a live lookup with a fraud score. It sends the caller’s number
|
||||
to IPQualityScore. The free plan has 1,000 lookups a month.
|
||||
</label>
|
||||
{v.useIpqs && (
|
||||
<>
|
||||
{keyField(
|
||||
'ipqsKey',
|
||||
'API key',
|
||||
!!sp.ipqs?.hasKey,
|
||||
'From your IPQualityScore account settings.',
|
||||
)}
|
||||
<SpamTest provider="ipqs" keyText={v.ipqsKey} />
|
||||
{st.ipqsPausedAt && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Paused since {when(st.ipqsPausedAt)}: its lookups are used up until they come back.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">FTC complaint list</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={v.useFtc} onChange={flag('useFtc')} className="size-4" />
|
||||
Use the FTC list: complaints people filed with the FTC about unwanted calls, downloaded to
|
||||
this server. Free, and nothing about your calls is sent out. The reports aren’t
|
||||
verified, and spammers fake real people’s numbers.
|
||||
</label>
|
||||
{v.useFtc && (
|
||||
<>
|
||||
{keyField(
|
||||
'ftcKey',
|
||||
'api.data.gov key',
|
||||
!!sp.ftc?.hasKey,
|
||||
'Free, from api.data.gov/signup.',
|
||||
)}
|
||||
<SpamTest provider="ftc" keyText={v.ftcKey} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{num('syncHours', 'Download again every (hours, 1–168)')}
|
||||
{num('windowDays', 'Keep complaints for (days, 7–365)')}
|
||||
</div>
|
||||
{st.ftcLastSyncAt && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Last download {when(st.ftcLastSyncAt)}
|
||||
{st.ftcLastError ? `: ${st.ftcLastError}` : '.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Keep IPQualityScore answers</legend>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{num('flaggedDays', 'Possible or likely spam (days, 1–365)')}
|
||||
{num('cleanDays', 'Everything else (days, 1–365)')}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Desk phones</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Shown in front of the caller’s name when the answer comes in before the phone rings.
|
||||
Letters, digits, spaces and . ' & @ - only.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Likely spam">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
maxLength={20}
|
||||
className={inputClass}
|
||||
value={v.high}
|
||||
onChange={text('high')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Possible spam">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
maxLength={20}
|
||||
className={inputClass}
|
||||
value={v.medium}
|
||||
onChange={text('medium')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{save.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Send callers' numbers to IPQualityScore?"
|
||||
message={
|
||||
<>
|
||||
The numbers of outside callers who aren’t in your contacts will be sent to
|
||||
IPQualityScore. You may need to say so in your privacy notice, and to have a data
|
||||
processing agreement with them. In the EU and UK the GDPR applies.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Turn on"
|
||||
onConfirm={submit}
|
||||
onCancel={() => setConfirming(false)}
|
||||
busy={save.isPending}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = (s: string) =>
|
||||
s
|
||||
.split(/[\n,]/)
|
||||
|
||||
Reference in new issue
Block a user