view mod_http_upload_external/README.md @ 6299:5cf5ee23b361

mod_voipms: New Module to send and receive SMS/MMS via VoIP.ms APIs. diff --git a/mod_voipms/README.md b/mod_voipms/README.md new file mode 100644 --- /dev/null +++ b/mod_voipms/README.md @@ -0,0 +1,51 @@ +--- +labels: +- 'Stage-Alpha' +- 'Type-Web' +summary: Send and receive SMS/MMS via VoIP.ms APIs. +rockspec: + build: + modules: + mod_voipms: mod_voipms.lua +... + +Introduction +============ + +This is a Prosody module to map JIDs to DIDs on VoIP.ms and support sending/receiving SMS/MMS. + +Configuration +============= + +| option | type | default | +|-----------------------|--------|---------| +| voipms\_api\_username | string | nil | +| voipms\_api\_password | string | nil | +| voipms\_query\_key | string | nil | +| voipms\_jid\_map | table | nil | +``` +VirtualHost "sms.example.com" +modules_enabled = { + "voipms"; +} +voipms_api_username = john@example.com -- E-mail registered with VoIP.ms +voipms_api_password = abcd1234 -- API password configured in VoIP.ms +voipms_query_key = some_query_key -- query param 'key' part of your URL callback +voipms_jid_map = { + ["your_jid@your_domain.com"] = "+1234567890" +} +``` + +HTTP +==== + +The module is served on Prosody's default HTTP ports at the path /voipms. More details on configuring HTTP modules in Prosody can be found in the HTTP documentation. + +VoIP.ms Webhook URL +=================== + +This module receives the VoIP.ms Webhook URL (POST) at the /voipms endpoint. It uses the sendSMS/sendMMS GET methods against the VoIP.ms APIs. This is an example webhook to use in VoIP.ms: + +``` +https://sms.example.com/voipms?key=some_query_key +``` diff --git a/mod_voipms/mod_voipms.lua b/mod_voipms/mod_voipms.lua new file mode 100644 --- /dev/null +++ b/mod_voipms/mod_voipms.lua @@ -0,0 +1,166 @@ +local http = require "net.http" +local json = require "util.json" +local st = require "util.stanza" + +local api_username = module:get_option_string("voipms_api_username") +local api_password = module:get_option_string("voipms_api_password") +local query_key = module:get_option("voipms_query_key") +local jid_map = module:get_option("voipms_jid_map") or {} +local rest_endpoint = "https://voip.ms/api/v1/rest.php" + +if not api_username or not api_password or not query_key then + module:log("error", "Missing required config values (voipms_api_username, voipms_api_password, voipms_query_key)") + return +end + +module:depends("http") + +local function normalize_number(num) + if not num then return nil end + if num:sub(1, 1) ~= "+" then + return "+1" .. num + end + return num +end + +local function extract_query_key(event) + return (event.request.url.query or ""):match("key=([^&]+)") +end + +module:provides("http", { + route = { + ["POST"] = function(event) + local req = event.request + local body = req.body or "" + + if extract_query_key(event) ~= query_key then + module:log("warn", "Unauthorized webhook: missing or invalid key") + return { status_code = 403 } + end + + local json_payload, err = json.decode(body) + if not json_payload then + module:log("warn", "Invalid JSON: %s", err or "unknown error") + return { status_code = 400 } + end + + local payload = json_payload.data and json_payload.data.payload + if not payload then + module:log("warn", "Missing payload in JSON") + return { status_code = 400 } + end + + local from = payload.from and payload.from.phone_number + local to_list = payload.to or {} + local to = #to_list > 0 and to_list[1].phone_number or nil + + if not from or not to then + module:log("warn", "Missing phone numbers (from: %s, to: %s)", tostring(from), tostring(to)) + return { status_code = 400 } + end + + local normalized_to = normalize_number(to) + local target_jid = nil + + for jid, did in pairs(jid_map) do + if normalize_number(did) == normalized_to then + target_jid = jid + break + end + end + + if not target_jid then + module:log("warn", "No JID mapping for DID %s", normalized_to) + return { status_code = 404 } + end + + local normalized_from = normalize_number(from) + local message_text = payload.text or "" + + local message = st.message({ + from = normalized_from .. "@" .. module.host, + to = target_jid, + type = "chat" + }):tag("body"):text(message_text):up() + + if payload.media and #payload.media > 0 then + for _, media_item in ipairs(payload.media) do + if media_item.url then + message_text = message_text .. "\n" .. media_item.url + message:tag("x", { xmlns = "jabber:x:oob" }) + :tag("url"):text(media_item.url):up():up() + end + end + end + + module:send(message) + module:log("info", "Delivered SMS from %s to %s", normalized_from, target_jid) + + return { status_code = 204 } + end + } +}) + +module:hook("message/bare", function(event) + local stanza = event.stanza + if stanza.attr.type ~= "chat" then return end + + local from_jid = stanza.attr.from + local to_jid = stanza.attr.to + local body = stanza:get_child_text("body") + if not body or body == "" then return end + + local from_number = jid_map[from_jid] + if not from_number then + module:log("warn", "No DID mapping for JID %s", from_jid) + return + end + + local to_number = to_jid:match("^([^@]+)") + if not to_number then + module:log("warn", "Malformed JID in message.to: %s", to_jid) + return + end + + to_number = normalize_number(to_number) + + local media_urls = {} + for line in body:gmatch("[^\r\n]+") do + if line:match("^https?://") then + table.insert(media_urls, line) + end + end + + local method = (#media_urls > 0) and "sendMMS" or "sendSMS" + local query = { + api_username = api_username, + api_password = api_password, + method = method, + did = from_number, + dst = to_number, + message = body + } + + if method == "sendMMS" then + for i, url in ipairs(media_urls) do + query["media_url[" .. (i - 1) .. "]"] = url + end + end + + local query_str = http.formencode(query) + + http.request(rest_endpoint .. "?" .. query_str, { + method = "GET"; + }, function(response_body, code) + if code == 200 then + local resp, err = json.decode(response_body) + if not resp or resp.status ~= "success" then + module:log("error", "Failed to send %s: %s", method, err or (resp and resp.status) or "unknown") + else + module:log("info", "Sent %s from %s to %s", method, from_number, to_number) + end + else + module:log("error", "HTTP error sending %s: code %s", method, tostring(code)) + end + end) +end)
author Chaz <menel@snikket.de>
date Mon, 21 Jul 2025 23:57:37 +0200
parents fe0a58b863db
children 68e8f14bef05 1cbdd4086e09
line wrap: on
line source

---
description: HTTP File Upload (external service)
labels:
- Stage-Beta
---

Introduction
============

This module implements [XEP-0363], which lets clients upload files
over HTTP to an external web server.

This module generates URLs that are signed using a HMAC. Any web service that can authenticate
these URLs can be used.

Implementations
---------------

* [PHP implementation](https://hg.prosody.im/prosody-modules/raw-file/tip/mod_http_upload_external/share.php)
* [Python3+Flask implementation](https://github.com/horazont/xmpp-http-upload)
* [Go implementation, Prosody Filer](https://github.com/ThomasLeister/prosody-filer)
* [Perl implementation for nginx](https://github.com/weiss/ngx_http_upload)
* [Rust implementation](https://gitlab.com/nyovaya/xmpp-http-upload)

To implement your own service compatible with this module, check out the implementation notes below
(and if you publish your implementation - let us know!).

Configuration
=============

The module can be added as a new Component definition:

``` {.lua}
Component "upload.example.org" "http_upload_external"
http_upload_external_base_url = "https://your.example.com/upload/service"
http_upload_external_secret = "your shared secret"
```

It should **not** be added to modules_enabled.


External URL
------------

You need to provide the path to the external service. Ensure it ends with '/'.

For example, to use the PHP implementation linked above, you might set it to:

``` {.lua}
http_upload_external_base_url = "https://your.example.com/path/to/share.php/"
```

Secret
------

Set a long and unpredictable string as your secret. This is so the upload service can verify that
the upload comes from mod_http_upload_external, and random strangers can't upload to your server.

``` {.lua}
http_upload_external_secret = "this is a secret string!"
```

You need to set exactly the same secret string in your external service.

Limits
------

A maximum file size can be set by:

``` {.lua}
http_upload_external_file_size_limit = 123 -- bytes
```

Default is 100MB (100\*1024\*1024).

Access
------

You may want to give upload access to additional entities such as components
by using the `http_upload_external_access` config option.

``` {.lua}
http_upload_external_access = {"gateway.example.com"};
```

Compatibility
=============

  Prosody-Version   Status
  ----------------- ----------------------
  trunk             Works as of 25-06-13
  13.0              Works
  0.12              Works

Implementation
==============

To implement your own external service that is compatible with this module, you need to expose a
simple API that allows the HTTP GET, HEAD and PUT methods on arbitrary URLs located on your service.

For example, if http_upload_external_base_url is set to `https://example.com/upload/` then your service
might receive the following requests:

Upload a new file:

```
PUT https://example.com/upload/foo/bar.jpg?v=49e9309ff543ace93d25be90635ba8e9965c4f23fc885b2d86c947a5d59e55b2
```

Recipient checks the file size and other headers:

```
HEAD https://example.com/upload/foo/bar.jpg
```

Recipient downloads the file:

```
GET https://example.com/upload/foo/bar.jpg
```

The only tricky logic is in validation of the PUT request. Firstly, don't overwrite existing files (return 409 Conflict).

Then you need to validate the auth token.

### Validating the auth token


| Version | Supports                                                                                                |
|:--------|:--------------------------------------------------------------------------------------------------------|
| v       | Validates only filename and size. Does not support file type restrictions by the XMPP server.           |
| v2      | Validates the filename, size and MIME type. This allows the server to implement MIME type restrictions. |

It is probable that a future v3 will be specified that allows carrying information about the uploader identity, allowing
the implementation of per-user quotas and limits.

Implementations may implement one or more versions of the protocol simultaneously. The XMPP server generates the URLs and
ultimately selects which version will be used.

XMPP servers MUST only generate URLs with **one** of the versions listed here. However in case multiple parameters are
present, upload services MUST **only** use the token from the highest parameter version that they support.

#### Version 1 (v)

The token will be in the URL query parameter 'v'. If it is absent, fail with 403 Forbidden.

Calculate the expected auth token by reading the value of the Content-Length header of the PUT request. E.g. for a 1MB file
will have a Content-Length of '1048576'. Append this to the uploaded file name, separated by a space (0x20) character.

For the above example, you would end up with the following string: "foo/bar.jpg 1048576"

The auth token is a SHA256 HMAC of this string, using the configured secret as the key. E.g.

```
calculated_auth_token = hmac_sha256("foo/bar.jpg 1048576", "secret string")
```

If this is not equal to the 'v' parameter provided in the upload URL, reject the upload with 403 Forbidden.

**Security note:** When comparing `calculated_auth_token` with the token provided in the URL, you must use a constant-time string
comparison, otherwise an attacker may be able to discover your secret key. Most languages/environments provide such a function, such
as `hash_equals()` in PHP, `hmac.compare_digest()` in Python, or `ConstantTimeCompare()` from `crypto/subtle` in Go.

#### Version 2 (v2)

The token will be in the URL query parameter 'v2'. If it is absent, fail with 403 Forbidden.

| Input           | Example     |Read from                                                            |
|:----------------|:------------|:--------------------------------------------------------------------|
|`file_path`      | foo/bar.jpg | The URL of the PUT request, with the service's base prefix removed. |
|`content_length` | 1048576     | Content-Length header                                               |
|`content_type`   | image/jpeg  | Content-Type header                                                 |

The parameters should be joined into a single string, separated by NUL bytes (`\0`):

```
  signed_string = ( file_path + '\0' + content_length + '\0' + content_type )
```

```
  signed_string = "foo/bar.jpg\01048576\0image/jpeg"
```

The expected auth token is the SHA256 HMAC of this string, using the configured secret key as the key. E.g.:

```
calculated_auth_token = hmac_sha256(signed_string, "secret string")
```

If this is not equal to the 'v2' parameter provided in the upload URL, reject the upload with 403 Forbidden.

**Security note:** When comparing `calculated_auth_token` with the token provided in the URL, you must use a constant-time string
comparison, otherwise an attacker may be able to discover your secret key. Most languages/environments provide such a function, such
as `hash_equals()` in PHP, `hmac.compare_digest()` in Python, or `ConstantTimeCompare()` from `crypto/subtle` in Go.

### Security considerations

#### HTTPS

All uploads and downloads should only be over HTTPS. The security of the served content is protected only
by the uniqueness present in the URLs themselves, and not using HTTPS may leak the URLs and contents to third-parties.

Implementations should consider including HSTS and HPKP headers, with consent of the administrator.

#### MIME types

If the upload Content-Type header matches any of the following MIME types, it MUST be preserved and included in the Content-Type
of any GET requests made to download the file:

- `image/*`
- `video/*`
- `audio/*`
- `text/plain`

It is recommended that other MIME types are preserved, but served with the addition of the following header:

```
Content-Disposition: attachment
```

This prevents the browser interpreting scripts and other resources that may potentially be malicious.

Some browsers may also benefit from explicitly telling them not to try guessing the type of a file:

```
X-Content-Type-Options: nosniff
```

#### Security headers

The following headers should be included to provide additional sandboxing of resources, considering the uploaded
content is not understood or trusted by the upload service:

```
Content-Security-Policy: default-src 'none'
X-Content-Security-Policy: default-src 'none'
X-WebKit-CSP: default-src 'none'
```