mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
Require trusted loopback requests for proactive send, accept hyphenated WhatsApp group JIDs, and serialize access-grant mutations to avoid lost concurrent updates. Co-authored-by: Cursor <cursoragent@cursor.com>
165 lines
5 KiB
JavaScript
165 lines
5 KiB
JavaScript
import { createDeliveryRpcHandler, DELIVERY_ENDPOINTS } from './delivery-rpc.mjs';
|
|
|
|
export const DELIVERY_HTTP_PATH = '/api/dsh-im/delivery/messages';
|
|
|
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
|
|
/**
|
|
* Same loopback / same-origin gate as DSH Host APIs: delivery must not be
|
|
* callable from a LAN-bound Host or cross-site browser navigation.
|
|
* @param {import('node:http').IncomingMessage} request
|
|
*/
|
|
export function isTrustedDeliveryRequest(request) {
|
|
const host = request.headers.host ?? '';
|
|
if (!host) return false;
|
|
const hostname = host.split(':')[0].replace(/^\[|\]$/g, '');
|
|
if ((request.headers['sec-fetch-site'] ?? '') === 'cross-site') return false;
|
|
const origin = request.headers.origin;
|
|
if (origin !== undefined && origin !== 'null') {
|
|
try {
|
|
if (new URL(origin).host !== host) return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
const remoteHost = String(request.socket?.remoteAddress || '')
|
|
.replace(/^::ffff:/i, '');
|
|
if (remoteHost && remoteHost !== '127.0.0.1' && remoteHost !== '::1') {
|
|
return false;
|
|
}
|
|
return hostname === '127.0.0.1' || hostname === 'localhost'
|
|
|| hostname === '::1' || hostname === '0.0.0.0';
|
|
}
|
|
|
|
const DELIVERY_ERROR_STATUS = Object.freeze({
|
|
'bad-request': 400,
|
|
'unknown-bot': 404,
|
|
'unknown-target': 404,
|
|
'target-conflict': 409,
|
|
'invalid-target': 422,
|
|
'bot-not-connected': 503,
|
|
'target-rejected': 422,
|
|
'delivery-failed': 502,
|
|
cancelled: 408,
|
|
});
|
|
|
|
class HttpRequestError extends Error {
|
|
constructor(status, code) {
|
|
super(code);
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function json(response, status, value, headers = {}) {
|
|
if (response.destroyed || response.writableEnded) return;
|
|
const body = JSON.stringify(value);
|
|
response.writeHead(status, {
|
|
'cache-control': 'no-store',
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'content-length': String(Buffer.byteLength(body)),
|
|
...headers,
|
|
});
|
|
response.end(body);
|
|
}
|
|
|
|
function isJsonContentType(value) {
|
|
return typeof value === 'string'
|
|
&& value.split(';', 1)[0].trim().toLowerCase() === 'application/json';
|
|
}
|
|
|
|
async function readJsonBody(request) {
|
|
const declaredLength = Number(request.headers['content-length']);
|
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
throw new HttpRequestError(413, 'payload-too-large');
|
|
}
|
|
|
|
const chunks = [];
|
|
let length = 0;
|
|
for await (const chunk of request) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
length += buffer.length;
|
|
if (length > MAX_BODY_BYTES) {
|
|
throw new HttpRequestError(413, 'payload-too-large');
|
|
}
|
|
chunks.push(buffer);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
|
|
} catch {
|
|
throw new HttpRequestError(400, 'bad-request');
|
|
}
|
|
}
|
|
|
|
export function createDeliveryHttpHandler(service) {
|
|
const dispatch = createDeliveryRpcHandler(service);
|
|
return async (request, response) => {
|
|
if (!isTrustedDeliveryRequest(request)) {
|
|
json(response, 403, {
|
|
error: { code: 'forbidden', message: 'forbidden', details: {} },
|
|
});
|
|
return;
|
|
}
|
|
if (request.method !== 'POST') {
|
|
json(response, 405, {
|
|
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
|
|
}, { allow: 'POST' });
|
|
return;
|
|
}
|
|
if (!isJsonContentType(request.headers['content-type'])) {
|
|
json(response, 415, {
|
|
error: { code: 'unsupported-media-type', message: 'unsupported-media-type', details: {} },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const abort = new AbortController();
|
|
const cancel = () => abort.abort();
|
|
const cancelClosedResponse = () => {
|
|
if (!response.writableEnded) cancel();
|
|
};
|
|
request.once('aborted', cancel);
|
|
response.once('close', cancelClosedResponse);
|
|
try {
|
|
const payload = await readJsonBody(request);
|
|
const result = await dispatch(DELIVERY_ENDPOINTS.send, payload, abort.signal);
|
|
if (result.ok) {
|
|
json(response, 200, result.value);
|
|
return;
|
|
}
|
|
json(response, DELIVERY_ERROR_STATUS[result.error.code] ?? 500, {
|
|
error: result.error,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof HttpRequestError) {
|
|
json(response, error.status, {
|
|
error: { code: error.code, message: error.code, details: {} },
|
|
});
|
|
return;
|
|
}
|
|
json(response, 500, {
|
|
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
|
|
});
|
|
} finally {
|
|
request.off('aborted', cancel);
|
|
response.off('close', cancelClosedResponse);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function installDeliveryHttp(ctx, service) {
|
|
if (!ctx?.webServer || typeof ctx.webServer.register !== 'function'
|
|
|| typeof ctx.effect !== 'function') {
|
|
throw new TypeError('DSH Host WebServer is required');
|
|
}
|
|
const route = {
|
|
kind: 'exact',
|
|
path: DELIVERY_HTTP_PATH,
|
|
handler: createDeliveryHttpHandler(service),
|
|
};
|
|
return ctx.effect(
|
|
() => ctx.webServer.register(route),
|
|
`dsh-im: ${DELIVERY_HTTP_PATH}`,
|
|
);
|
|
}
|