feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,281 @@
|
||||
import { X509Certificate } from 'crypto';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
|
||||
/**
|
||||
* The one HTTP client both accounting drivers use.
|
||||
*
|
||||
* Built on node:http(s) rather than global fetch because a self-hosted
|
||||
* Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be
|
||||
* given a CA without an extra dependency. The reference "solved" that with
|
||||
* `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the
|
||||
* admin can paste the installation's certificate instead.
|
||||
*
|
||||
* A pasted certificate is trusted two ways: as a CA (the normal chain and
|
||||
* hostname checks), and as a pin — when the server presents exactly that
|
||||
* certificate it is accepted whatever name it was issued for. Tahesab's own
|
||||
* certificate is self-signed for `localhost`, so a remote install reached by
|
||||
* IP can only be verified by the pin.
|
||||
*
|
||||
* Errors say whether the request may have reached the server. That decides
|
||||
* what the outbox may do next: a refused connection is safe to retry, a
|
||||
* timeout after the request went out is not (Zarrin has no idempotency key).
|
||||
*/
|
||||
|
||||
export interface HttpRequest {
|
||||
method: 'GET' | 'POST';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs: number;
|
||||
/** PEM CA certificate(s) to trust in addition to the system store. */
|
||||
ca?: string | null;
|
||||
}
|
||||
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class AccountingHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** True when the request may have been received and acted on. */
|
||||
readonly ambiguous: boolean,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/;
|
||||
|
||||
/** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */
|
||||
export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] {
|
||||
const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
|
||||
const out: X509Certificate[] = [];
|
||||
for (const block of blocks) {
|
||||
try {
|
||||
out.push(new X509Certificate(block));
|
||||
} catch {
|
||||
// not a certificate — the CA list below will reject it too
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */
|
||||
export function pinnedFingerprints(pem: string | null | undefined): Set<string> {
|
||||
return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256));
|
||||
}
|
||||
|
||||
function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
|
||||
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
||||
return pinned
|
||||
? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبتشده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
|
||||
: `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
|
||||
}
|
||||
return pinned
|
||||
? `گواهی فعلی سرور با گواهی ثبتشده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
|
||||
: `گواهی TLS سرور قابلاعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
|
||||
}
|
||||
|
||||
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url);
|
||||
} catch {
|
||||
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
|
||||
}
|
||||
const isHttps = url.protocol === 'https:';
|
||||
if (!isHttps && url.protocol !== 'http:') {
|
||||
return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL'));
|
||||
}
|
||||
|
||||
const headers: Record<string, string | number> = { ...(req.headers ?? {}) };
|
||||
if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
method: req.method,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
};
|
||||
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
|
||||
if (isHttps) {
|
||||
options.rejectUnauthorized = true;
|
||||
if (req.ca && req.ca.trim()) {
|
||||
// Extra CA on top of the system store, never instead of verification.
|
||||
options.ca = [...tls.rootCertificates, req.ca];
|
||||
// Runs only after the chain verified. The exact pasted certificate is
|
||||
// accepted under any name; anything else gets the normal name check.
|
||||
// (Pooled sockets are keyed by `ca`, so a reused one had the same pins.)
|
||||
options.checkServerIdentity = (host, cert) =>
|
||||
pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<HttpResponse>((resolve, reject) => {
|
||||
let connected = false;
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const client = isHttps ? https : http;
|
||||
const request = client.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
request.destroy();
|
||||
finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE')));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () =>
|
||||
finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })),
|
||||
);
|
||||
res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE'))));
|
||||
});
|
||||
|
||||
request.on('socket', (socket) => {
|
||||
// A kept-alive socket is already connected; listening on it again would
|
||||
// pile up a listener per request.
|
||||
if (!(socket as { connecting?: boolean }).connecting) {
|
||||
connected = true;
|
||||
return;
|
||||
}
|
||||
socket.once(isHttps ? 'secureConnect' : 'connect', () => {
|
||||
connected = true;
|
||||
});
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
request.destroy();
|
||||
finish(() =>
|
||||
reject(
|
||||
new AccountingHttpError(
|
||||
connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)',
|
||||
connected,
|
||||
'TIMEOUT',
|
||||
),
|
||||
),
|
||||
);
|
||||
}, req.timeoutMs);
|
||||
|
||||
request.on('error', (err: NodeJS.ErrnoException) => {
|
||||
// Node's own text for these suggests flags that do not apply here.
|
||||
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
|
||||
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
|
||||
});
|
||||
|
||||
if (req.body !== undefined) request.write(req.body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export interface ServerCertificate {
|
||||
pem: string;
|
||||
subject: string;
|
||||
issuer: string;
|
||||
subjectAltName: string | null;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
expired: boolean;
|
||||
fingerprint256: string;
|
||||
selfSigned: boolean;
|
||||
/** Valid chain to a system CA and issued for this host: no pin needed. */
|
||||
trustedBySystem: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the certificate an https server presents, WITHOUT trusting it, so the
|
||||
* admin can compare its fingerprint with the one on the accounting server and
|
||||
* pin it. Nothing but the TLS handshake is sent.
|
||||
*/
|
||||
export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise<ServerCertificate> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS'));
|
||||
}
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
return new Promise<ServerCertificate>((resolve, reject) => {
|
||||
const socket = tls.connect({
|
||||
host,
|
||||
port: Number(url.port || 443),
|
||||
servername: net.isIP(host) ? undefined : host,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT'));
|
||||
}, timeoutMs);
|
||||
socket.once('secureConnect', () => {
|
||||
clearTimeout(timer);
|
||||
const peer = socket.getPeerCertificate();
|
||||
const chainOk = socket.authorized;
|
||||
socket.end();
|
||||
if (!peer?.raw) {
|
||||
reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT'));
|
||||
return;
|
||||
}
|
||||
const cert = new X509Certificate(peer.raw);
|
||||
const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host);
|
||||
const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n');
|
||||
resolve({
|
||||
pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`,
|
||||
subject: cert.subject,
|
||||
issuer: cert.issuer,
|
||||
subjectAltName: cert.subjectAltName ?? null,
|
||||
validFrom: cert.validFrom,
|
||||
validTo: cert.validTo,
|
||||
expired: new Date(cert.validTo).getTime() < Date.now(),
|
||||
fingerprint256: cert.fingerprint256,
|
||||
// checkIssued() also wants keyCertSign, which Tahesab's certificate lacks.
|
||||
selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey),
|
||||
trustedBySystem: chainOk && nameOk,
|
||||
});
|
||||
});
|
||||
socket.once('error', (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
reject(new AccountingHttpError(err.message, false, err.code));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON body that may carry junk around it. Tahesab replies have been
|
||||
* seen with a BOM, raw control characters and unescaped backslashes inside
|
||||
* strings (henza_server TahesabController::test had to scrub them the same
|
||||
* way before json_decode would accept the body).
|
||||
*/
|
||||
export function parseLooseJson(body: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
// fall through to the scrubbed attempt
|
||||
}
|
||||
let raw = body.replace(/^/, '');
|
||||
raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-');
|
||||
const start = raw.search(/[{[]/);
|
||||
if (start > 0) raw = raw.slice(start);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
Reference in New Issue
Block a user