All files / routes/admin/whatsapp messages.routes.ts

100% Statements 35/35
91.66% Branches 22/24
100% Functions 2/2
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                                  1x     1x               3x 2x 2x                                 1x 14x 14x 14x 14x   14x 12x 2x       10x 10x   1x     9x     9x 1x     8x     8x 8x           1x     7x 1x       6x                         14x           14x     14x                 14x 14x 14x     14x   2x          
import { Hono } from "hono";
import type { Dal } from "../../../dal";
import { error, handleError } from "../../../lib/response";
import { parseRequiredId, requireUser } from "../../../lib/utils";
import { requireWhatsAppClient } from "../../../lib/whatsapp/client";
import type { Services } from "../../../services";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
	};
};
 
const messages = new Hono<Env>();
 
/** The inbound types whose stored content carries a Meta `mediaId`. */
const MEDIA_TYPES = new Set(["image", "video", "document", "audio"]);
 
/**
 * A filename comes from the customer's phone. Strip anything that could break
 * out of the header (CR/LF) or its quoted-string; the percent-encoded
 * `filename*` form carries the rest safely.
 */
function safeFilename(name: unknown): string | null {
	if (typeof name !== "string") return null;
	const cleaned = name.replace(/[\r\n"\\]/g, "").trim();
	return cleaned.length > 0 ? cleaned.slice(0, 200) : null;
}
 
/**
 * GET /:messageId/media — stream the bytes of one stored media message.
 *
 * Keyed on the MESSAGE, never on a media id from the client: authorisation is
 * anchored on a row the admin can already see in the thread. A `mediaId`
 * parameter would let anyone with an admin session pull any media id Meta
 * would serve this WABA, including from conversations this deployment never
 * stored.
 *
 * Meta media is not a public URL — it takes an authenticated Graph lookup for
 * a short-lived URL, then an authenticated fetch of that URL. Neither the URL
 * nor the bearer token may appear in a response or a log, so this proxies
 * rather than redirects.
 */
messages.get("/:messageId/media", async (c) => {
	try {
		requireUser(c.get("user"));
		const dal = c.get("dal");
		const id = parseRequiredId(c.req.param("messageId"), "message");
 
		const message = await dal.waMessages.findById(id);
		if (!message || !MEDIA_TYPES.has(message.type)) {
			return error(c, "NOT_FOUND", "Media not found", 404);
		}
 
		let parsed: Record<string, unknown>;
		try {
			parsed = JSON.parse(message.content) as Record<string, unknown>;
		} catch {
			return error(c, "NOT_FOUND", "Media not found", 404);
		}
 
		const mediaId = parsed.mediaId;
		// Outbound media is stored as `{ link }` — we sent Meta a public URL and
		// never held a media id for it. Nothing to proxy.
		if (typeof mediaId !== "string" || mediaId.length === 0) {
			return error(c, "NOT_FOUND", "Media not found", 404);
		}
 
		const client = requireWhatsAppClient(c.env);
 
		let upstream: Response;
		try {
			upstream = await client.fetchMedia(mediaId);
		} catch {
			// Deliberately swallowed: the thrown error carries Meta's status for
			// the media LOOKUP, and `handleError` would translate it through the
			// send-failure copy table into something nonsensical. Old media that
			// Meta has expired is the common case and reads as "gone", not "500".
			return error(c, "MEDIA_UNAVAILABLE", "Media is no longer available", 404);
		}
 
		if (!upstream.ok || !upstream.body) {
			return error(c, "MEDIA_UNAVAILABLE", "Media is no longer available", 404);
		}
 
		const filename =
			message.type === "document" ? safeFilename(parsed.filename) : null;
 
		// A document is ALWAYS a download, filename or not.
		//
		// Its content type is whatever the sender's phone declared, so a
		// customer could attach an .html file and Meta would hand it back as
		// `text/html`. Served `inline`, that renders as a page on the API's own
		// origin, in a browser carrying an admin session — a stored XSS with the
		// attacker being anyone who can message the business number.
		// `nosniff` does not help: it stops a browser guessing a type, not
		// honouring a declared one. Only pictures, video and audio may render
		// in place, and those are the types the thread actually displays.
		const disposition =
			message.type === "document"
				? filename
					? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
					: "attachment"
				: "inline";
 
		const headers = new Headers();
		// Only the headers we choose — forwarding Meta's wholesale would leak
		// its cache tags and cookies into a customer conversation response.
		headers.set(
			"Content-Type",
			upstream.headers.get("content-type") || "application/octet-stream",
		);
		// No Content-Length forward: the Workers runtime decompresses the
		// upstream body, so Meta's length would describe the compressed bytes
		// and disagree with the stream we send. The edge sizes it for us.
		// Immutable once sent, but PRIVATE: this is a customer conversation and
		// must never sit in a shared CDN cache.
		headers.set("Cache-Control", "private, max-age=31536000, immutable");
		headers.set("X-Content-Type-Options", "nosniff");
		headers.set("Content-Disposition", disposition);
 
		// Streamed, not buffered: a 16MB video must not land in a Worker's heap.
		return new Response(upstream.body, { headers });
	} catch (err) {
		return handleError(c, err);
	}
});
 
export default messages;