feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { isIP } from 'node:net';
|
||||
import { parse } from 'tldts';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { detectDrainageContent, normalizeContent, type DrainageDetectionMatch } from './drainage-content-detection';
|
||||
|
||||
export const DRAINAGE_POLICY_VERSION = 'domain-boundary-v1';
|
||||
export class DrainageRejection extends BadRequestException {
|
||||
constructor(
|
||||
public readonly reasonCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
super({ code: reasonCode, message: reason });
|
||||
}
|
||||
}
|
||||
export type DrainageTarget = { key: string; category: string; value: string; text: string; start: number; end: number };
|
||||
export type DrainageMaterial = {
|
||||
id: string;
|
||||
url: string;
|
||||
auditStatus: string;
|
||||
materialVersion: number;
|
||||
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
|
||||
};
|
||||
export type DrainageAssessment = {
|
||||
version: string;
|
||||
evaluatedAt: string;
|
||||
targets: Array<DrainageTarget & { materialIds: string[] }>;
|
||||
materials: DrainageMaterial[];
|
||||
allowedChannelIds: string[] | null;
|
||||
reasonCode: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
export function drainageHost(raw: string) {
|
||||
const value = raw
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
|
||||
.replace(/[。。]/g, '.')
|
||||
.trim();
|
||||
try {
|
||||
const parsed = new URL(/^[a-z]+:\/\//i.test(value) ? value : `https://${value}`);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||
const host = parsed.hostname.toLowerCase().replace(/\.$/, '');
|
||||
if (isIP(host)) return host;
|
||||
const domain = parse(host, { allowPrivateDomains: true });
|
||||
return domain.domain && domain.isIcann !== false ? host : domain.domain && domain.isPrivate ? host : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDrainagePhone(value: string) {
|
||||
return normalizeContent(value, 'landline').text.replace(/[()]/g, '');
|
||||
}
|
||||
|
||||
export function materialMatches(target: DrainageTarget, raw: string) {
|
||||
if (target.category !== 'url') return normalizeDrainagePhone(raw) === target.value;
|
||||
const host = drainageHost(raw);
|
||||
if (!host || !(target.value === host || (!isIP(host) && target.value.endsWith(`.${host}`)))) return false;
|
||||
// Existing path-specific material does not silently authorize unrelated paths.
|
||||
const normalizedRaw = normalizeContent(raw, 'url').text.trim();
|
||||
const registered = new URL(/^[a-z]+:\/\//i.test(normalizedRaw) ? normalizedRaw : `https://${normalizedRaw}`);
|
||||
if (registered.pathname !== '/' || registered.search) {
|
||||
const candidate = new URL(/^[a-z]+:\/\//i.test(target.text) ? target.text : `https://${target.text}`);
|
||||
return (
|
||||
(candidate.pathname === registered.pathname ||
|
||||
candidate.pathname.startsWith(`${registered.pathname.replace(/\/$/, '')}/`)) &&
|
||||
(!registered.search || candidate.search === registered.search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function drainageTargets(content: string, matches: DrainageDetectionMatch[]) {
|
||||
const urls: DrainageTarget[] = [];
|
||||
const normalized = normalizeContent(content, 'url');
|
||||
for (const match of matches.filter((item) => item.category === 'url')) {
|
||||
let start = normalized.sourceStarts.findIndex((offset) => offset >= match.start);
|
||||
let end = normalized.sourceEnds.findIndex((offset) => offset >= match.end) + 1;
|
||||
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
|
||||
// Extend the entire URL token, including suffix labels, userInfo and query.
|
||||
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
|
||||
while (start > 0 && token.test(normalized.text[start - 1])) start--;
|
||||
while (end < normalized.text.length && token.test(normalized.text[end])) end++;
|
||||
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
|
||||
const value = drainageHost(text);
|
||||
if (!value) throw new DrainageRejection('DRAINAGE_INVALID', '引流URL格式无效或不是可登记域名');
|
||||
urls.push({
|
||||
key: `url:${value}:${text}`,
|
||||
category: 'url',
|
||||
value,
|
||||
text,
|
||||
start: normalized.sourceStarts[start],
|
||||
end: normalized.sourceEnds[end - 1],
|
||||
});
|
||||
}
|
||||
const targets = [...urls];
|
||||
for (const match of matches.filter((item) => item.category !== 'url')) {
|
||||
if (urls.some((url) => match.start < url.end && match.end > url.start)) continue;
|
||||
if (!['mobile', 'landline'].includes(match.category))
|
||||
throw new ServiceUnavailableException('引流识别类型尚未支持发送校验');
|
||||
const value = normalizeDrainagePhone(match.normalizedText);
|
||||
targets.push({
|
||||
key: `phone:${value}`,
|
||||
category: match.category,
|
||||
value,
|
||||
text: match.text,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
});
|
||||
}
|
||||
return [...new Map(targets.map((target) => [target.key, target])).values()];
|
||||
}
|
||||
|
||||
export function assessDrainage(
|
||||
targets: DrainageTarget[],
|
||||
materials: DrainageMaterial[],
|
||||
carrier?: string,
|
||||
): DrainageAssessment {
|
||||
let allowed: Set<string> | null = null;
|
||||
const assessment: DrainageAssessment = {
|
||||
version: DRAINAGE_POLICY_VERSION,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
targets: [],
|
||||
materials: [],
|
||||
allowedChannelIds: null,
|
||||
reasonCode: null,
|
||||
reason: null,
|
||||
};
|
||||
for (const target of targets) {
|
||||
const matching = materials.filter((item) => item.auditStatus !== 'deleted' && materialMatches(target, item.url));
|
||||
const approved = matching.filter((item) => item.auditStatus === 'approved');
|
||||
assessment.targets.push({ ...target, materialIds: approved.map((item) => item.id) });
|
||||
const code = !matching.length ? 'DRAINAGE_NOT_REGISTERED' : !approved.length ? 'DRAINAGE_NOT_APPROVED' : null;
|
||||
if (code && !assessment.reasonCode) {
|
||||
assessment.reasonCode = code;
|
||||
assessment.reason = `引流信息“${target.text.slice(0, 160)}”${!matching.length ? '未在当前签名下添加' : '尚未审核通过'}`;
|
||||
}
|
||||
const channels = new Set(
|
||||
approved.flatMap((item) =>
|
||||
item.reportTasks
|
||||
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
|
||||
.map((task) => task.channelId),
|
||||
),
|
||||
);
|
||||
allowed =
|
||||
allowed === null
|
||||
? channels
|
||||
: new Set<string>(Array.from(allowed as Set<string>).filter((id: string) => channels.has(id)));
|
||||
}
|
||||
const used = new Set(assessment.targets.flatMap((target) => target.materialIds));
|
||||
assessment.materials = materials
|
||||
.filter((item) => used.has(item.id))
|
||||
.map(({ id, url, auditStatus, materialVersion, reportTasks }) => ({
|
||||
id,
|
||||
url,
|
||||
auditStatus,
|
||||
materialVersion,
|
||||
reportTasks,
|
||||
}));
|
||||
assessment.allowedChannelIds = allowed === null ? null : [...allowed];
|
||||
if (targets.length && allowed?.size === 0 && !assessment.reasonCode) {
|
||||
assessment.reasonCode = 'DRAINAGE_CHANNEL_NOT_APPROVED';
|
||||
assessment.reason = '当前签名下的全部引流信息没有共同报备通过的通道';
|
||||
}
|
||||
return assessment;
|
||||
}
|
||||
|
||||
export async function evaluateMessageDrainage(
|
||||
prisma: PrismaService,
|
||||
message: {
|
||||
id: string;
|
||||
content: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
signatureId?: string | null;
|
||||
},
|
||||
carrier?: string,
|
||||
materials?: DrainageMaterial[],
|
||||
fresh = false,
|
||||
) {
|
||||
let detected;
|
||||
try {
|
||||
detected = await detectDrainageContent(prisma, message.content, fresh);
|
||||
} catch (error) {
|
||||
throw new ServiceUnavailableException('引流检测暂不可用', { cause: error });
|
||||
}
|
||||
const detection = detected.drainageDetection as unknown as {
|
||||
matches: DrainageDetectionMatch[];
|
||||
truncated: boolean;
|
||||
ruleCount: number;
|
||||
};
|
||||
if (detection.truncated || detection.ruleCount === 0)
|
||||
throw new ServiceUnavailableException('引流检测不完整,暂不能发送');
|
||||
let targets: DrainageTarget[] = [];
|
||||
let invalid: DrainageRejection | undefined;
|
||||
try {
|
||||
targets = drainageTargets(message.content, detection.matches);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DrainageRejection)) throw error;
|
||||
invalid = error;
|
||||
}
|
||||
const rows =
|
||||
materials ??
|
||||
(targets.length && message.signatureId
|
||||
? await prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
signatureId: message.signatureId,
|
||||
tenantId: message.tenantId ?? '',
|
||||
applicationId: message.applicationId ?? '',
|
||||
auditStatus: { not: 'deleted' },
|
||||
},
|
||||
include: {
|
||||
reportTasks: {
|
||||
where: { reportType: 'drainage', signatureId: message.signatureId, tenantId: message.tenantId ?? '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
: []);
|
||||
const assessment = assessDrainage(targets, rows, carrier);
|
||||
if (invalid) {
|
||||
assessment.reasonCode = invalid.reasonCode;
|
||||
assessment.reason = invalid.message;
|
||||
assessment.allowedChannelIds = [];
|
||||
}
|
||||
// Append-only evidence survives later approval changes and subsequent routing attempts.
|
||||
await prisma.smsDrainageDecision.create({
|
||||
data: { messageRecordId: message.id, snapshot: JSON.parse(JSON.stringify(assessment)) },
|
||||
});
|
||||
await prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
...detected,
|
||||
drainageGate: JSON.parse(JSON.stringify(assessment)),
|
||||
drainageInfoId:
|
||||
assessment.targets.length === 1 && assessment.targets[0].materialIds.length === 1
|
||||
? assessment.targets[0].materialIds[0]
|
||||
: null,
|
||||
},
|
||||
});
|
||||
if (assessment.reasonCode) throw new DrainageRejection(assessment.reasonCode, assessment.reason!);
|
||||
return assessment;
|
||||
}
|
||||
Reference in New Issue
Block a user