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.
+ }
+}