comparison mod_muc_cache_media/mod_muc_cache_media.lua @ 6412:c25b98d42ed0

mod_muc_cache_media: Initial commit
author Stephen Paul Weber <singpolyma@singpolyma.net>
date Sun, 22 Feb 2026 01:08:33 -0500
parents
children 48fca0914fcf
comparison
equal deleted inserted replaced
6411:b00638d74f46 6412:c25b98d42ed0
1 local http = require "net.http"
2 local resolver = require "net.resolvers.basic"
3 local socket_url = require "socket.url"
4 local st = require "util.stanza"
5 local async = require "util.async"
6 local promise = require "util.promise"
7 local mime = require "mime"
8 local lfs = require "lfs"
9 local hashes = require "util.hashes"
10 local jid = require "util.jid"
11
12 local storage_path = module:get_option_string("muc_media_store_path", "/var/lib/prosody/muc-media")
13 local public_base_url = module:get_option_string("muc_media_public_base", "https://cache.example.com")
14 local max_size = module:get_option_number("muc_media_max_size", 10 * 1024 * 1024)
15 local MAX_REDIRECTS = 5
16
17 lfs.mkdir(storage_path)
18
19 local mime_extensions = {
20 ["image/jpeg"] = ".jpg",
21 ["image/jpg"] = ".jpg",
22 ["image/png"] = ".png",
23 ["image/gif"] = ".gif",
24 ["image/webp"] = ".webp",
25 ["image/avif"] = ".avif",
26 ["video/mp4"] = ".mp4",
27 ["video/webm"] = ".webm",
28 ["audio/mpeg"] = ".mp3",
29 ["audio/ogg"] = ".ogg",
30 }
31
32 local function extension_from_content_type(content_type)
33 if not content_type then return ".bin" end
34 local clean = content_type:match("^[^;]+")
35 clean = clean and clean:lower()
36 return mime_extensions[clean] or ".bin"
37 end
38
39 local function ipv4_to_number(ip)
40 local a,b,c,d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
41 if not a then return nil end
42 return (tonumber(a) << 24) | (tonumber(b) << 16) | (tonumber(c) << 8) | tonumber(d)
43 end
44
45 local function ipv4_in_range(ip, base, mask) return (ip & mask) == base end
46
47 local function is_private_ipv4(ip)
48 local n = ipv4_to_number(ip)
49 if not n then return false end
50 if ipv4_in_range(n, 0x0A000000, 0xFF000000) then return true end
51 if ipv4_in_range(n, 0xAC100000, 0xFFF00000) then return true end
52 if ipv4_in_range(n, 0xC0A80000, 0xFFFF0000) then return true end
53 if ipv4_in_range(n, 0x7F000000, 0xFF000000) then return true end
54 if ipv4_in_range(n, 0xA9FE0000, 0xFFFF0000) then return true end
55 if ipv4_in_range(n, 0x00000000, 0xFF000000) then return true end
56 if ipv4_in_range(n, 0xE0000000, 0xF0000000) then return true end
57 return false
58 end
59
60 local function is_private_ipv6(ip)
61 ip = ip:lower()
62 if ip == "::1" then return true end
63 if ip:match("^fc") or ip:match("^fd") then return true end
64 if ip:match("^fe8") or ip:match("^fe9") or ip:match("^fea") or ip:match("^feb") then return true end
65 if ip == "::" then return true end
66 return false
67 end
68
69 local function is_safe_host(host)
70 if not host then return false end
71 host = host:lower()
72 if host == "localhost" then return false end
73 local records = socket_dns.getaddrinfo(host)
74 if not records then return false end
75 for _, r in ipairs(records) do
76 local ip = r.addr
77 if ip:match("^%d+%.%d+%.%d+%.%d+$") then
78 if is_private_ipv4(ip) then return false end
79 else
80 if is_private_ipv6(ip) then return false end
81 end
82 end
83 return true
84 end
85
86 local function sha256_file(path)
87 local f = io.open(path,"rb")
88 if not f then return nil end
89 -- TODO: incremental hash
90 local digest = hashes.sha256(f:read("*all"))
91 f:close()
92 return mime.b64(digest)
93 end
94
95 local function sha3_256_file(path)
96 local f = io.open(path,"rb")
97 if not f then return nil end
98 -- TODO: incremental hash
99 local digest = hashes.sha3_256(f:read("*all"))
100 f:close()
101 return mime.b64(digest)
102 end
103
104 local function file_size(path)
105 return lfs.attributes(path,"size") or 0
106 end
107
108 local function filename_from_url(url)
109 return url:match(".*/([^/?]+)$") or "file"
110 end
111
112 local function resolve_redirects(url)
113 return promise.new(function(resolve, reject)
114 local current_url = url
115 local redirects = 0
116
117 local function step()
118 if redirects > MAX_REDIRECTS then reject("too many redirects"); return end
119 http.request(current_url,{
120 method="HEAD";
121 redirect=false;
122 }, function(_, code, response)
123 if not code or code == 0 then reject("request failed"); return end
124 if code>=300 and code<400 and response.headers.location then
125 redirects = redirects + 1
126 local location = response.headers.location
127 local parsed = socket_url.parse(current_url)
128 if not location:match("^https?://") then
129 location = parsed.scheme.."://"..parsed.host..(parsed.port and ":"..parsed.port or "")..location
130 end
131 current_url = location
132 step()
133 return
134 end
135 resolve(current_url)
136 end)
137 end
138
139 step()
140 end)
141 end
142
143 local function all_safe(resolv, didnext, net, ip)
144 if didnext and not ip then
145 return promise.resolve(true)
146 end
147
148 if not didnext or (net == "tcp4" and not is_private_ipv4(ip))
149 or (net == "tcp6" and not is_private_ipv6(ip)) then
150 return promise.new(function(resolve)
151 resolv:next(function(net,ip,port,extra)
152 all_safe(resolv, true, net, ip):next(resolve)
153 end)
154 end)
155 else
156 return promise.resolve(false)
157 end
158 end
159
160 local function secure_download_to_disk(original_url)
161 return resolve_redirects(original_url):next(function(final_url)
162 local parsed = socket_url.parse(final_url)
163 if not parsed or (parsed.scheme~="http" and parsed.scheme~="https") then return promise.reject("invalid scheme") end
164
165 return all_safe(resolver.new(parsed.host, 443, "tcp", { servername = parsed.host })):next(function(is_all_safe)
166 if not is_all_safe then return promise.reject("no safe IP") end
167
168 local tmpname = storage_path.."/tmp_"..tostring(math.random(1e9))
169 local file = io.open(tmpname,"wb")
170 if not file then return promise.reject("cannot open temp file") end
171
172 return promise.new(function(resolve, reject)
173 http.request(final_url,{
174 method = "GET";
175 redirect = false;
176 body_size_limit = max_size;
177 streaming_handler = function(r) return file, function() file:close(); file = nil end end;
178 }, function(_, code, response)
179 if code ~= 200 then
180 if file then file:close() end
181 os.remove(tmpname)
182 reject("bad status")
183 return
184 end
185 if file then
186 file:write(response.body)
187 file:close()
188 end
189 resolve({ tmpname, response.headers["content-type"] })
190 end)
191 end)
192 end)
193 end)
194 end
195
196 module:hook("muc-broadcast-message", function(event)
197 local stanza = event.stanza
198 if stanza.attr.type ~= "groupchat" then return end
199
200 local url_map = {}
201
202 -- Collect OOB URLs
203 for oob in stanza:childtags("x","jabber:x:oob") do
204 local url_tag = oob:get_child("url")
205 if url_tag then
206 url_map[url_tag:get_text()] = {}
207 end
208 end
209
210 -- Collect SIMS URLs
211 for ref in stanza:childtags("reference","urn:xmpp:reference:0") do
212 for ms in ref:childtags("media-sharing","urn:xmpp:sims:1") do
213 local sources = ms:get_child("sources")
214 if sources then
215 for sref in sources:childtags("reference","urn:xmpp:reference:0") do
216 local uri = sref.attr.uri
217 if uri then url_map[uri] = {} end
218 end
219 end
220 end
221 end
222
223 -- Cache each URL
224 for original_url,_ in pairs(url_map) do
225 local result, err = async.wait_for(secure_download_to_disk(original_url))
226 local tmp_path, content_type = result and result[1], result and result[2]
227 if not err and tmp_path then
228 local sha2 = sha256_file(tmp_path)
229 local sha3 = sha3_256_file(tmp_path)
230 local size = file_size(tmp_path)
231 local name = filename_from_url(original_url)
232 if sha2 then
233 local ext = extension_from_content_type(content_type)
234 local final_name = sha2:gsub("+","-"):gsub("/","_"):gsub("=","")..ext
235 local final_path = storage_path.."/"..final_name
236 if not lfs.attributes(final_path) then
237 os.rename(tmp_path, final_path)
238 else
239 os.remove(tmp_path)
240 end
241 url_map[original_url] = { local_url = public_base_url.."/"..final_name, path=final_path, sha2=sha2, sha3=sha3, size=size, name=name, content_type=content_type }
242 else
243 os.remove(tmp_path)
244 end
245 end
246 end
247
248 -- Rewrite URLs and enrich/create SIMS
249 local original_body = stanza:get_child_text("body")
250 for original_url,data in pairs(url_map) do
251 if data.local_url then
252
253 for oob in stanza:childtags("x","jabber:x:oob") do
254 local url_tag = oob:get_child("url")
255 if url_tag and url_tag:get_text() == original_url then
256 oob:maptags(function(el)
257 if el.name == "url" then
258 return st.stanza("url"):text(data.local_url)
259 else
260 return el
261 end
262 end)
263 end
264 end
265
266 stanza:maptags(function(el)
267 if el.name == "body" then
268 return st.stanza("body"):text(el:get_text():gsub(original_url, data.local_url))
269 elseif el.name == "fallback" and el.attr.xmlns == "urn:xmpp:fallback:0" and (el.attr["for"] == "jabber:x:oob" or el.attr["for"] == "urn:xmpp:sims:1") then
270 return el:maptags(function(subel)
271 local s, e = string.find(original_body, original_url)
272 if subel.name == "body" and math.abs(s - tonumber(subel.attr.start)) < 5 then
273 subel.attr["end"] = tostring((tonumber(subel.attr["end"]) - #original_url) + #data.local_url)
274 end
275 return subel
276 end)
277 else
278 return el
279 end
280 end)
281
282 local found = false
283 -- Enrich existing SIMS references
284 for ref in stanza:childtags("reference","urn:xmpp:reference:0") do
285 for ms in ref:childtags("media-sharing","urn:xmpp:sims:1") do
286 local sources = ms:get_child("sources")
287 if sources then
288 for sref in sources:childtags("reference","urn:xmpp:reference:0") do
289 if sref.attr.uri == original_url then
290 found = true
291 sref.attr.uri = data.local_url
292 local file_tag = ms:get_child("file","urn:xmpp:jingle:apps:file-transfer:5")
293 if not file_tag then
294 file_tag = st.stanza("file",{ xmlns="urn:xmpp:jingle:apps:file-transfer:5" })
295 ms:add_child(file_tag)
296 end
297 file_tag:maptags(function(el)
298 if el.name == "size" or el.name == "hash" then
299 return nil
300 else
301 return el
302 end
303 end)
304 -- Fill name
305 if not file_tag:get_child("name") then
306 file_tag:add_child(st.stanza("name"):text(data.name))
307 end
308 -- Fill size
309 if not file_tag:get_child("size") then
310 file_tag:add_child(st.stanza("size"):text(tostring(data.size)))
311 end
312 -- Fill SHA256 hash
313 local sha2_tag = nil
314 for htag in file_tag:childtags("hash","urn:xmpp:hashes:2") do
315 if htag.attr.algo=="sha2-256" then sha2_tag = htag end
316 end
317 if not sha2_tag then
318 file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha2-256" }):text(data.sha2))
319 end
320 if data.sha3 then
321 -- Fill SHA3-256 hash
322 local sha3_tag = nil
323 for htag in file_tag:childtags("hash","urn:xmpp:hashes:2") do
324 if htag.attr.algo=="sha3-256" then sha3_tag = htag end
325 end
326 if not sha3_tag then
327 file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha3-256" }):text(data.sha3))
328 end
329 end
330 end
331 end
332 end
333 end
334 end
335 -- Create new SIMS if none existed
336 if not found then
337 local ref = st.stanza("reference",{ xmlns="urn:xmpp:reference:0", type="data" })
338 local ms = st.stanza("media-sharing",{ xmlns="urn:xmpp:sims:1" })
339 local file_tag = st.stanza("file",{ xmlns="urn:xmpp:jingle:apps:file-transfer:5" })
340 file_tag:add_child(st.stanza("media-type"):text(data.content_type or "application/octet-stream"))
341 file_tag:add_child(st.stanza("name"):text(data.name))
342 file_tag:add_child(st.stanza("size"):text(tostring(data.size)))
343 file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha2-256" }):text(data.sha2))
344 if data.sha3 then file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha3-256" }):text(data.sha3)) end
345 ms:add_child(file_tag)
346 local sources = st.stanza("sources")
347 sources:add_child(st.stanza("reference",{ xmlns="urn:xmpp:reference:0", type="data", uri=data.local_url }))
348 ms:add_child(sources)
349 ref:add_child(ms)
350 stanza:add_child(ref)
351 end
352 end
353 end
354 end, 100)
355
356 local archive = module:open_store("muc_log", "archive");
357
358 module:hook("muc-moderate-message", function(event)
359 local room = event.room
360 local stanza_id = event.stanza_id
361 if not room or not stanza_id then return end
362
363 local room_node = jid.split(room.jid);
364
365 -- Find message by stanza_id
366 local query = {
367 with = "message<groupchat";
368 ids = { stanza_id };
369 }
370
371 local items, err = archive:find(room_node, query)
372 if not items or err then return end
373
374 for id, stanza, when in items do
375 -- Extract SIMS hashes
376 for ref in stanza:childtags("reference", "urn:xmpp:reference:0") do
377 for ms in ref:childtags("media-sharing", "urn:xmpp:sims:1") do
378 local file_tag = ms:get_child("file", "urn:xmpp:jingle:apps:file-transfer:5")
379 if file_tag then
380 for hash_tag in file_tag:childtags("hash", "urn:xmpp:hashes:2") do
381 if hash_tag.attr.algo == "sha2-256" then
382 local sha2 = hash_tag:get_text()
383 if sha2 then
384 local prefix = sha2:gsub("+","-"):gsub("/","_"):gsub("=","")
385 -- Files are named: hash + extension
386 for file in lfs.dir(storage_path) do
387 if file:match("^" .. prefix) then
388 local path = storage_path .. "/" .. file
389 if lfs.attributes(path) then
390 os.remove(path)
391 module:log("debug",
392 "Deleted cached media %s for moderated message %s",
393 file, stanza_id
394 )
395 end
396 end
397 end
398 end
399 end
400 end
401 end
402 end
403 end
404 end
405 end)