diff --git a/chatplan.xml b/chatplan.xml index c6a3667..ed70ec6 100644 --- a/chatplan.xml +++ b/chatplan.xml @@ -11,14 +11,6 @@ - - - - - - - diff --git a/outbound-hook.php b/outbound-hook.php index ea04fd9..adb6c63 100644 --- a/outbound-hook.php +++ b/outbound-hook.php @@ -73,6 +73,34 @@ require __DIR__."/providers/".$provider.".php"; +$message_uuid = $dedupeID; + +// Unwrap CPIM envelope by inner content type. Post-Fix-2f, Linphone wraps ALL +// outbound messages in CPIM — SMS, IMDN, typing indicators, and MMS alike. +// Dispatch on the inner type so the existing per-type cases still work. +if ($contentType === 'message/cpim') { + $cpim = CPIM::fromString($body); + $innerContentType = $cpim->getHeader('content-type'); + + // Drop notifications that have no carrier-side equivalent + if ($innerContentType && ( + stripos($innerContentType, 'message/imdn') !== false || + stripos($innerContentType, 'application/im-iscomposing') !== false + )) { + error_log("outbound-hook: dropping CPIM-wrapped ".$innerContentType); + http_response_code(200); + die(); + } + + // Unwrap text/plain so the existing text/plain case handles carrier SMS dispatch + if ($innerContentType && stripos($innerContentType, 'text/plain') !== false) { + $body = $cpim->body; + $contentType = 'text/plain'; + } + + // Otherwise (file-transfer XML): fall through to the message/cpim case +} + switch($contentType) { case "text/plain": $message_uuid = Messages::OutgoingSMS($extensionUUID, $domainUUID, $from, $to, $body, $message_uuid); diff --git a/providers/accelerate-networks.php b/providers/accelerate-networks.php index e46f05a..2d9b79f 100644 --- a/providers/accelerate-networks.php +++ b/providers/accelerate-networks.php @@ -44,7 +44,13 @@ function incoming() if ($body->{'MessageType'} == "0") { $success = Messages::IncomingSMS($body->{'From'}, $body->{'To'}, $body->{'Content'}); } else { - $success = Messages::IncomingMMS($body->{'From'}, $body->{'To'}, $body->{'MediaURLs'}, $body->{'AdditionalRecipients'}); + // `Content` is a comma-separated filename list aligned with `MediaURLs` by index + // (e.g. "part-003.txt,part-002.mp4,part-001.SMIL,"). Usually has a trailing comma. + $filenames = array_values(array_filter( + array_map('trim', explode(',', $body->{'Content'} ?? '')), + fn($f) => $f !== '' + )); + $success = Messages::IncomingMMS($body->{'From'}, $body->{'To'}, $body->{'MediaURLs'}, $filenames, $body->{'AdditionalRecipients'}); } if ($success) { diff --git a/src/CPIM.php b/src/CPIM.php index 4e3d75d..ae9e3dd 100644 --- a/src/CPIM.php +++ b/src/CPIM.php @@ -9,6 +9,7 @@ final class CPIM { public array $headers; + public string $body = ''; public string $filename; public int $fileSize; public $fileContentType; @@ -22,6 +23,45 @@ public function __construct() $this->headers = array(); } + /** + * Build a CPIM carrying an RCS file-transfer reference. + * + * @param string $filename display name of the attachment + * @param int $fileSize size of the attachment in bytes + * @param string $fileContentType MIME type of the attachment + * @param string $fileURL URL the receiving client will download from + * + * @return CPIM configured in file-transfer mode + */ + public static function forFileTransfer( + string $filename, + int $fileSize, + string $fileContentType, + string $fileURL + ): self { + $cpim = new self(); + $cpim->filename = $filename; + $cpim->fileSize = $fileSize; + $cpim->fileContentType = $fileContentType; + $cpim->fileURL = $fileURL; + return $cpim; + } + + /** + * Build a CPIM carrying an inline text body. + * + * @param string $body plain text content + * + * @return CPIM configured in text-body mode + */ + public static function forText(string $body): self + { + $cpim = new self(); + $cpim->body = $body; + $cpim->headers['Content-Type'] = 'text/plain'; + return $cpim; + } + /** * Parse a block of encoded headers * @@ -55,12 +95,22 @@ public static function fromString(string $raw): self { $message = new self(); - $parts = explode("\n\n", $raw); - for($i = 0; $i < sizeof($parts)-2; $i++) { + $parts = explode("\n\n", str_replace("\r\n", "\n", $raw)); + for($i = 0; $i < sizeof($parts)-1; $i++) { $message->_addHeaders($parts[$i]); } $rawBody = $parts[sizeof($parts)-1]; - + $message->body = $rawBody; + + // Linphone emits bare `&` in URL attribute values (technically-invalid XML). + // Pre-escape so xml_parse_into_struct can handle file-transfer payloads. + // Negative lookahead skips already-escaped entities to avoid double-encoding. + $rawBody = preg_replace( + '/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/', + '&', + $rawBody + ); + $parser = xml_parser_create(); xml_parse_into_struct($parser, $rawBody, $body); @@ -70,7 +120,7 @@ public static function fromString(string $raw): self switch($tag['tag']) { case "FILE-SIZE": $message->fileSize = (int)$tag['value']; - fileContentType; + break; case "FILE-NAME": $message->filename = $tag['value']; break; @@ -93,66 +143,80 @@ public static function fromString(string $raw): self */ public function toString(): string { - $xw = xmlwriter_open_memory(); - xmlwriter_set_indent($xw, true); + if (isset($this->fileURL)) { + $xw = xmlwriter_open_memory(); + xmlwriter_set_indent($xw, true); - xmlwriter_start_document($xw, '1.0', 'UTF-8'); + xmlwriter_start_document($xw, '1.0', 'UTF-8'); - xmlwriter_start_element($xw, 'file'); + xmlwriter_start_element($xw, 'file'); - xmlwriter_start_attribute($xw, 'xmlns'); - xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:fthttp'); - xmlwriter_end_attribute($xw); + xmlwriter_start_attribute($xw, 'xmlns'); + xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:fthttp'); + xmlwriter_end_attribute($xw); - xmlwriter_start_attribute($xw, 'xmlns:am'); - xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:rram'); - xmlwriter_end_attribute($xw); + xmlwriter_start_attribute($xw, 'xmlns:am'); + xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:rram'); + xmlwriter_end_attribute($xw); - xmlwriter_start_element($xw, 'file-info'); - xmlwriter_start_attribute($xw, 'type'); - xmlwriter_text($xw, 'file'); + xmlwriter_start_element($xw, 'file-info'); + xmlwriter_write_attribute($xw, 'type', 'file'); - if (isset($this->fileSize)) { - xmlwriter_start_attribute($xw, 'file-size'); - xmlwriter_text($xw, (string)$this->fileSize); - xmlwriter_end_element($xw); - } + if (isset($this->fileSize)) { + xmlwriter_start_element($xw, 'file-size'); + xmlwriter_text($xw, (string)$this->fileSize); + xmlwriter_end_element($xw); + } - if (isset($this->filename)) { - xmlwriter_start_element($xw, 'file-name'); - xmlwriter_text($xw, $this->filename); - xmlwriter_end_element($xw); - } + if (isset($this->filename)) { + xmlwriter_start_element($xw, 'file-name'); + xmlwriter_text($xw, $this->filename); + xmlwriter_end_element($xw); + } - if (isset($this->fileContentType)) { - xmlwriter_start_element($xw, 'content-type'); - xmlwriter_text($xw, $this->fileContentType); - xmlwriter_end_element($xw); - } + if (isset($this->fileContentType)) { + xmlwriter_start_element($xw, 'content-type'); + xmlwriter_text($xw, $this->fileContentType); + xmlwriter_end_element($xw); + } - if (isset($this->fileURL)) { xmlwriter_start_element($xw, 'data'); - xmlwriter_start_attribute($xw, 'url'); - xmlwriter_text($xw, $this->fileURL); + xmlwriter_write_attribute($xw, 'url', $this->fileURL); xmlwriter_end_element($xw); - } - xmlwriter_end_element($xw); + xmlwriter_end_element($xw); // close file-info - xmlwriter_end_element($xw); + xmlwriter_end_element($xw); // close file - $body = xmlwriter_output_memory($xw); + $body = xmlwriter_output_memory($xw); + $defaultContentType = 'application/vnd.gsma.rcs-ft-http+xml'; + } else { + $body = $this->body; + $defaultContentType = 'text/plain'; + } - if (!array_key_exists('content-length', $this->headers)) { - $this->headers['content-length'] = strlen($body); + // Normalize content header keys to Linphone-compatible case (uppercase T/L). + // Linphone's CPIM parser uses case-sensitive exact match for "Content-Type" + // and "Content-Length" (see cpim-message.cpp getContentHeader lookup). + foreach (['content-type' => 'Content-Type', 'content-length' => 'Content-Length', 'Content-type' => 'Content-Type', 'Content-length' => 'Content-Length'] as $variant => $proper) { + if (array_key_exists($variant, $this->headers)) { + $this->headers[$proper] = $this->headers[$variant]; + if ($variant !== $proper) { + unset($this->headers[$variant]); + } + } + } + + if (!array_key_exists('Content-Length', $this->headers)) { + $this->headers['Content-Length'] = strlen($body); } - if (!array_key_exists('content-type', $this->headers)) { - $this->headers['content-type'] = "application/vnd.gsma.rcs-ft-http+xml"; + if (!array_key_exists('Content-Type', $this->headers)) { + $this->headers['Content-Type'] = $defaultContentType; } - $contentHeaders = array("content-type", "content-length"); + $contentHeaders = array("Content-Type", "Content-Length"); $firstHeaderBlock = array(); foreach ($this->headers as $key=>$value) { @@ -172,7 +236,7 @@ public function toString(): string $secondHeaderBlock[] = $key.": ".$this->headers[$key]; } - $out = implode("\n", $firstHeaderBlock)."\n\n".implode("\n", $secondHeaderBlock)."\n\n".$body; + $out = implode("\r\n", $firstHeaderBlock)."\r\n\r\n".implode("\r\n", $secondHeaderBlock)."\r\n\r\n".$body; return $out; } diff --git a/src/Messages.php b/src/Messages.php index b3e5517..29a7195 100644 --- a/src/Messages.php +++ b/src/Messages.php @@ -1,6 +1,6 @@ headers["From"] = "sip:" . $from . "@" . $destination->domainName; - $cpim->headers["To"] = "sip:" . $destination->extension . "@" . $destination->domainName; + $sharedHeaders = []; + $sharedHeaders['From'] = "domainName . ">"; + $sharedHeaders['To'] = "extension . "@" . $destination->domainName . ">"; $cc = array(); foreach ($additionalRecipients as $number) { $cc[] = "<" . $number . "@" . $destination->domainName . ">"; } - $cpim->headers["CC"] = implode(", ", $cc); + if (!empty($cc)) { + $sharedHeaders['CC'] = implode(", ", $cc); + } + $sharedHeaders['DateTime'] = gmdate("Y-m-d\TH:i:s\Z"); $groupUUID = Messages::_findGroup($destination, $from, $to, $additionalRecipients); if ($groupUUID) { - $cpim->headers["Group-UUID"] = $groupUUID; + $sharedHeaders['Group-UUID'] = $groupUUID; } - foreach ($attachments as $attachment) { + foreach ($attachments as $i => $attachment) { $info = S3Helper::GetInfo($attachment); if ($info['ContentType'] == "application/smil") { continue; } - $c = clone $cpim; - $c->fileURL = $attachment; - $c->fileContentType = $info['ContentType']; - $c->fileSize = $info['ContentLength']; + if (stripos($info['ContentType'], 'text/plain') === 0) { + $textContent = S3Helper::Download($attachment); + $c = CPIM::forText($textContent); + } else { + $filename = $filenames[$i] ?? basename(parse_url($attachment, PHP_URL_PATH)); + $c = CPIM::forFileTransfer( + $filename, + (int)$info['ContentLength'], + $info['ContentType'], + $attachment + ); + } + + $c->headers = array_merge($sharedHeaders, $c->headers); + Messages::_incoming($destination, $from, $to, $c, "message/cpim", $groupUUID); } @@ -77,14 +90,17 @@ private static function _incoming(LocalNumber $destination, string $from, string $messageUUID = Messages::Save('incoming', $destination->extensionUUID, $destination->domainUUID, $from, $to, $bodyStr, $contentType, $message_uuid, $groupUUID); // generate a pre-signed download URL before delivering it to things that will download it - if ($body instanceof CPIM) { + if ($body instanceof CPIM && isset($body->fileURL)) { $body->fileURL = S3Helper::GetDownloadURL($body->fileURL); $bodyStr = $body->toString(); // deliver the webpush notification with "MMS Message" instead of an xml - //todo figure out how to add groupuuid to the payload + //todo figure out how to add groupuuid to the payload Messages::_sendWebPush($destination->domainUUID, $destination->extensionUUID, $from, $to, "MMS Message", $groupUUID); + } else if ($body instanceof CPIM) { + // text-body CPIM (inline text caption): no URL to presign + Messages::_sendWebPush($destination->domainUUID, $destination->extensionUUID, $from, $to, $body->body, $groupUUID); } else { - // deliver the webpush notification + // deliver the webpush notification Messages::_sendWebPush($destination->domainUUID, $destination->extensionUUID, $from, $to, $bodyStr, $groupUUID); } @@ -130,18 +146,28 @@ public static function OutgoingMMS(string $extensionUUID, string $domainUUID, st public static function _outgoing(LocalNumber $source, string $to, string $from, $body, string $contentType, string $messageUUID, ?string $groupUUID) { + // Normalize to canonical path-style unsigned URL. Guarantees DB durability + // (stored URLs never expire) regardless of URL shape the caller passed in: + // path-style or virtual-hosted, signed or unsigned — all collapse to the + // canonical form. + if ($body instanceof CPIM && $body->fileURL !== null) { + $body->fileURL = S3Helper::canonicalize($body->fileURL); + } + $bodyStr = ($body instanceof CPIM) ? $body->toString() : $body; + if ($groupUUID) { $response = Messages::Save('outgoing', $source->extensionUUID, $source->domainUUID, $from, $to, $bodyStr, $contentType, $messageUUID, $groupUUID); } else { $response = Messages::Save('outgoing', $source->extensionUUID, $source->domainUUID, $from, $to, $bodyStr, $contentType, $messageUUID, null); - } - // generate a pre-signed download URL before delivering it to things that will download it - if ($body instanceof CPIM) { + + // Re-sign for delivery. Always safe to run — canonicalize() guaranteed the URL + // is unsigned path-style, which GetDownloadURL's strip logic handles. + if ($body instanceof CPIM && $body->fileURL !== null) { $body->fileURL = S3Helper::GetDownloadURL($body->fileURL); - $bodyStr = $body->toString(); } + return $response; } diff --git a/src/S3Helper.php b/src/S3Helper.php index c6decdc..ac79e72 100644 --- a/src/S3Helper.php +++ b/src/S3Helper.php @@ -34,35 +34,41 @@ private static function _getS3Client(): Aws\S3\S3Client ); } - public static function GetDownloadURL(string $unsigned): string + public static function GetDownloadURL(string $url): string { - $prefix = $_SESSION['webtexting']['mms_bucket_endpoint']['text']."/".$_SESSION['webtexting']['mms_bucket']['text']."/"; - $objectKey = substr($unsigned, strlen($prefix)); - $s3 = S3Helper::_getS3Client(); $cmd = $s3->getCommand( 'GetObject', [ 'Bucket' => $_SESSION['webtexting']['mms_bucket']['text'], - 'Key' => $objectKey, + 'Key' => S3Helper::_extractKey($url), ] ); $request = $s3->createPresignedRequest($cmd, '+1 day'); - + return $request->getUri(); } public static function GetInfo(string $url) { - $prefix = $_SESSION['webtexting']['mms_bucket_endpoint']['text']."/".$_SESSION['webtexting']['mms_bucket']['text']."/"; - $objectKey = substr($url, strlen($prefix)); - return S3Helper::_getS3Client()->HeadObject( [ 'Bucket' => $_SESSION['webtexting']['mms_bucket']['text'], - 'Key' => $objectKey, + 'Key' => S3Helper::_extractKey($url), + ] + ); + } + + public static function Download(string $url): string + { + $result = S3Helper::_getS3Client()->getObject( + [ + 'Bucket' => $_SESSION['webtexting']['mms_bucket']['text'], + 'Key' => S3Helper::_extractKey($url), ] ); + + return (string)$result['Body']; } public static function GetUploadURL(string $uploadPath): string @@ -78,4 +84,64 @@ public static function GetUploadURL(string $uploadPath): string return $request->getUri(); } + + public static function UploadFile(string $key, string $filePath, string $contentType): string + { + $s3 = S3Helper::_getS3Client(); + $s3->putObject([ + 'Bucket' => $_SESSION['webtexting']['mms_bucket']['text'], + 'Key' => $key, + 'SourceFile' => $filePath, + 'ContentType' => $contentType, + ]); + return $_SESSION['webtexting']['mms_bucket_endpoint']['text'] + . "/" . $_SESSION['webtexting']['mms_bucket']['text'] + . "/" . $key; + } + + /** + * Return the canonical path-style unsigned URL for the object referenced by $url. + * Idempotent — already-canonical URLs pass through unchanged. + */ + public static function canonicalize(string $url): string + { + $key = S3Helper::_extractKey($url); + return $_SESSION['webtexting']['mms_bucket_endpoint']['text'] + . '/' . $_SESSION['webtexting']['mms_bucket']['text'] + . '/' . $key; + } + + /** + * Extract an S3 object key from any URL shape we might encounter. + * Handles: + * - path-style: https:////[?query] + * - virtual-hosted-style: https://./[?query] + * Signed URLs (with X-Amz-... query params) are accepted — the query string + * is stripped before extraction. + */ + private static function _extractKey(string $url): string + { + $endpoint = $_SESSION['webtexting']['mms_bucket_endpoint']['text']; + $bucket = $_SESSION['webtexting']['mms_bucket']['text']; + + $pathOnly = explode('?', $url, 2)[0]; + + $pathPrefix = $endpoint . '/' . $bucket . '/'; + if (strncmp($pathOnly, $pathPrefix, strlen($pathPrefix)) === 0) { + return substr($pathOnly, strlen($pathPrefix)); + } + + $parts = parse_url($endpoint); + if (!$parts || empty($parts['scheme']) || empty($parts['host'])) { + throw new \InvalidArgumentException("mms_bucket_endpoint is not a valid URL: {$endpoint}"); + } + $vhPrefix = $parts['scheme'] . '://' . $bucket . '.' . $parts['host'] . '/'; + if (strncmp($pathOnly, $vhPrefix, strlen($vhPrefix)) === 0) { + return substr($pathOnly, strlen($vhPrefix)); + } + + throw new \InvalidArgumentException( + "URL doesn't match path-style or virtual-hosted for bucket '{$bucket}': {$url}" + ); + } } diff --git a/tests/CPIMTest.php b/tests/CPIMTest.php index 814d765..14fd7fc 100644 --- a/tests/CPIMTest.php +++ b/tests/CPIMTest.php @@ -30,17 +30,22 @@ public function testParseCPIM(): void $this->assertSame("", $cpim->getHeader('from')); $this->assertSame("application/vnd.gsma.rcs-ft-http+xml", $cpim->getHeader('Content-Type')); - $this->assertSame("https://www.linphone.org:444//tmp/645c1f2741cff_dd766e8879da39b69c76.png", $cpim->file_url); - $this->assertSame("image/png", $cpim->file_content_type); - $this->assertSame("finn.png", $cpim->file_name); - $this->assertSame(286271, $cpim->file_size); + $this->assertSame("https://www.linphone.org:444//tmp/645c1f2741cff_dd766e8879da39b69c76.png", $cpim->fileURL); + $this->assertSame("image/png", $cpim->fileContentType); + $this->assertSame("finn.png", $cpim->filename); + $this->assertSame(286271, $cpim->fileSize); $this->assertSame(array("2024561414", "2065551212"), $cpim->getCC()); } public function testGenerateCPIM(): void { - $cpim = new CPIM(); + $cpim = CPIM::forFileTransfer( + "finn.png", + 286271, + "image/png", + "https://www.linphone.org:444//tmp/645c1f2741cff_dd766e8879da39b69c76.png" + ); $cpim->headers['From'] = ""; $cpim->headers['To'] = ""; $cpim->headers['DateTime'] = "2023-05-10T22:48:05Z"; @@ -48,31 +53,61 @@ public function testGenerateCPIM(): void $cpim->headers['imdn.Message-ID'] = "trpQpJEw9GKZ"; $cpim->headers['imdn.Disposition-Notification'] = "positive-delivery, negative-delivery, display"; - $cpim->file_name = "finn.png"; - $cpim->file_size = 286271; - $cpim->file_content_type = "image/png"; - $cpim->file_url = "https://www.linphone.org:444//tmp/645c1f2741cff_dd766e8879da39b69c76.png"; + $string = "From: \r\n" . + "To: \r\n" . + "DateTime: 2023-05-10T22:48:05Z\r\n" . + "NS: imdn \r\n" . + "imdn.Message-ID: trpQpJEw9GKZ\r\n" . + "imdn.Disposition-Notification: positive-delivery, negative-delivery, display\r\n" . + "\r\n" . + "Content-Type: application/vnd.gsma.rcs-ft-http+xml\r\n" . + "Content-Length: 382\r\n" . + "\r\n" . + "\n" . + "\n" . + " \n" . + " 286271\n" . + " finn.png\n" . + " image/png\n" . + " \n" . + " \n" . + "\n"; + $this->assertSame($string, $cpim->toString()); + } - $string = "From: -To: -DateTime: 2023-05-10T22:48:05Z -NS: imdn -imdn.Message-ID: trpQpJEw9GKZ -imdn.Disposition-Notification: positive-delivery, negative-delivery, display + public function testForFileTransferFactory(): void + { + $cpim = CPIM::forFileTransfer("video.mp4", 632327, "video/mp4", "https://example.com/video.mp4"); -content-type: application/vnd.gsma.rcs-ft-http+xml -content-length: 382 + $this->assertSame("video.mp4", $cpim->filename); + $this->assertSame(632327, $cpim->fileSize); + $this->assertSame("video/mp4", $cpim->fileContentType); + $this->assertSame("https://example.com/video.mp4", $cpim->fileURL); + $this->assertSame("", $cpim->body); + } - - - - 286271 - finn.png - image/png - - - -"; - $this->assertSame($string, $cpim->toString()); + public function testForTextFactory(): void + { + $cpim = CPIM::forText("Hello, Kenny"); + + $this->assertSame("Hello, Kenny", $cpim->body); + $this->assertSame("text/plain", $cpim->headers['Content-Type']); + $this->assertFalse(isset($cpim->fileURL)); + $this->assertFalse(isset($cpim->filename)); + + $cpim->headers['From'] = ""; + $cpim->headers['To'] = ""; + $cpim->headers['DateTime'] = "2026-04-22T05:09:43Z"; + + $expected = "From: \r\n" . + "To: \r\n" . + "DateTime: 2026-04-22T05:09:43Z\r\n" . + "\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Length: 12\r\n" . + "\r\n" . + "Hello, Kenny"; + + $this->assertSame($expected, $cpim->toString()); } } diff --git a/upload-hook.php b/upload-hook.php new file mode 100644 index 0000000..6abce4c --- /dev/null +++ b/upload-hook.php @@ -0,0 +1,242 @@ +select($sql, ['token' => $token], 'all'); +if (count($rows) !== 1) { + // UNIQUE constraint (Fix 7) enforces 0 or 1; fail closed on 2+. + error_log("upload-hook: auth rejected from $clientIp — matched ".count($rows)." rows"); + http_response_code(401); + die(); +} +$device = $rows[0]; + +// ── Phase 1: capability probe (empty body) ── +// Linphone's first POST has Content-Length: 0. Respond 204 or the client aborts. +$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); +if ($contentLength === 0) { + http_response_code(204); + die(); +} + +// ── Phase 2: multipart upload ── + +// Rate-limit check before doing any S3 work. +// SELECT failure here is treated as a hard request failure (503) — if we can't +// read the audit table to count quota, we have no way to enforce rate limiting, +// and silently letting the upload through would let a broken DB silently disable +// the throttle. Fail closed instead. +try { + $rateAllowed = upload_hook_rate_check($device['extension_uuid']); +} catch (\Throwable $e) { + error_log("upload-hook: rate-limit query failed for $clientIp: ".$e->getMessage()); + http_response_code(503); + die(); +} +if (!$rateAllowed) { + upload_hook_audit_log($device, $clientIp, null, null, null, 429); + http_response_code(429); + header('Retry-After: 3600'); + die(); +} + +if (!isset($_FILES['File']) || $_FILES['File']['error'] !== UPLOAD_ERR_OK) { + $phpErr = $_FILES['File']['error'] ?? 'unset'; + error_log("upload-hook: missing/failed File field (err=$phpErr) from $clientIp"); + upload_hook_audit_log($device, $clientIp, null, null, null, 400); + http_response_code(400); + die(); +} + +$file = $_FILES['File']; +$tmpPath = $file['tmp_name']; +$size = (int)$file['size']; + +// Cap is whatever nginx is passing via fastcgi_param. nginx enforces client_max_body_size +// at the edge; this check catches any slippage between the two nginx literals. +if ($size <= 0 || $size > $maxUploadBytes) { + upload_hook_audit_log($device, $clientIp, null, null, $size, 413); + http_response_code(413); + die(); +} + +// Filename: urldecode (Linphone bctbx_escape), then whitelist. Never trust +// $file['name'] directly as an S3 key or filesystem path. +$rawName = urldecode((string)$file['name']); +$cleanName = upload_hook_sanitize_filename($rawName); + +// Content-type: server-side detection; ignore client-declared $file['type']. +$detected = (new finfo(FILEINFO_MIME_TYPE))->file($tmpPath) ?: 'application/octet-stream'; +if (!upload_hook_content_type_allowed($detected)) { + error_log("upload-hook: rejected content-type '$detected' from $clientIp"); + upload_hook_audit_log($device, $clientIp, $cleanName, $detected, $size, 415); + http_response_code(415); + die(); +} + +// ── Load S3 credentials (same pattern as outbound-hook.php) ── +$credSql = "SELECT default_setting_subcategory, default_setting_value + FROM v_default_settings + WHERE default_setting_subcategory IN + ('mms_bucket','mms_bucket_endpoint','aws_access_key_id','aws_secret_key')"; +foreach ((new database)->select($credSql, [], 'all') as $cred) { + $_SESSION['webtexting'][$cred['default_setting_subcategory']]['text'] + = $cred['default_setting_value']; +} + +// ── Write to S3 (stream from disk; no file_get_contents of the whole blob) ── +// Unsigned URL — Messages::_outgoing() presigns for carrier delivery. +$key = 'outbound/'.uuid().$cleanName; +try { + $uploadedUrl = S3Helper::UploadFile($key, $tmpPath, $detected); +} catch (\Throwable $e) { + error_log("upload-hook: S3 write failed: ".$e->getMessage()); + upload_hook_audit_log($device, $clientIp, $cleanName, $detected, $size, 502); + http_response_code(502); + die(); +} + +// ── Build Linphone RCS response (lowercase tags; type="file" required) ── +$xw = xmlwriter_open_memory(); +xmlwriter_start_document($xw, '1.0', 'UTF-8'); +xmlwriter_start_element($xw, 'file'); +xmlwriter_write_attribute($xw, 'xmlns', 'urn:gsma:params:xml:ns:rcs:rcs:fthttp'); + +xmlwriter_start_element($xw, 'file-info'); +xmlwriter_write_attribute($xw, 'type', 'file'); + +xmlwriter_start_element($xw, 'file-size'); +xmlwriter_text($xw, (string)$size); +xmlwriter_end_element($xw); + +xmlwriter_start_element($xw, 'file-name'); +xmlwriter_text($xw, $cleanName); // raw — Linphone will bctbx_unescape +xmlwriter_end_element($xw); + +xmlwriter_start_element($xw, 'content-type'); +xmlwriter_text($xw, $detected); +xmlwriter_end_element($xw); + +xmlwriter_start_element($xw, 'data'); +xmlwriter_write_attribute($xw, 'url', $uploadedUrl); +xmlwriter_end_element($xw); + +xmlwriter_end_element($xw); // file-info +xmlwriter_end_element($xw); // file + +// Strict audit on success: rate-limit correctness depends on this row persisting. +// If the INSERT fails, abandon — Linphone marks NotDelivered, user retries; the +// orphan S3 object is cleaned up by the outbound/ lifecycle rule (Fix 8 — 7d retention). +try { + upload_hook_audit_log($device, $clientIp, $cleanName, $detected, $size, 200, /* strict */ true); +} catch (\Throwable $e) { + http_response_code(502); + die(); +} + +header('Content-Type: application/xml; charset=UTF-8'); +echo xmlwriter_output_memory($xw); + +// ── Helpers ── + +function upload_hook_sanitize_filename(string $raw): string +{ + $base = basename($raw); + $clean = preg_replace('/[^A-Za-z0-9._-]+/', '_', $base); + $clean = trim((string)$clean, '._'); + if ($clean === '') { + return 'file.bin'; + } + return substr($clean, 0, 200); +} + +function upload_hook_content_type_allowed(string $type): bool +{ + foreach (ALLOWED_TYPE_PREFIXES as $prefix) { + if (strncmp($type, $prefix, strlen($prefix)) === 0) return true; + } + return in_array($type, ALLOWED_TYPES_EXACT, true); +} + +function upload_hook_rate_check(string $extensionUuid): bool +{ + // Count successful uploads in the last hour for this extension. + $sql = "SELECT COUNT(*) AS n FROM linphone_upload_log + WHERE extension_uuid = :euuid + AND created_at > NOW() - INTERVAL '1 hour' + AND http_status = 200"; + $row = (new database)->select($sql, ['euuid' => $extensionUuid], 'row'); + return ((int)($row['n'] ?? 0)) < RATE_LIMIT_PER_HOUR; +} + +function upload_hook_audit_log(array $device, string $ip, + ?string $filename, ?string $contentType, + ?int $size, int $status, bool $strict = false): void +{ + $sql = "INSERT INTO linphone_upload_log + (log_uuid, extension_uuid, domain_uuid, device_uuid, + source_ip, filename, content_type, size_bytes, http_status, created_at) + VALUES (:log_uuid, :euuid, :duuid, :dvuuid, + :ip, :fn, :ct, :sz, :st, NOW())"; + try { + (new database)->execute($sql, [ + 'log_uuid' => uuid(), + 'euuid' => $device['extension_uuid'], + 'duuid' => $device['domain_uuid'], + 'dvuuid' => $device['device_uuid'], + 'ip' => $ip, + 'fn' => $filename, + 'ct' => $contentType, + 'sz' => $size, + 'st' => $status, + ]); + } catch (\Throwable $e) { + error_log("upload-hook: audit insert failed (status=$status): ".$e->getMessage()); + if ($strict) { + // Caller depends on this row persisting (rate-limit correctness for + // successful uploads). Re-throw so the caller can fail the request. + throw $e; + } + // Lenient default: failed-upload audit is diagnostic-only — log and swallow. + } +}