changeset 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 b00638d74f46
children 0703ef9f8db8
files mod_muc_cache_media/README.md mod_muc_cache_media/mod_muc_cache_media.lua
diffstat 2 files changed, 440 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_cache_media/README.md	Sun Feb 22 01:08:33 2026 -0500
@@ -0,0 +1,35 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: 'Cache media sent to a MUC'
+---
+
+Introduction
+============
+
+This checks for media coming in to the MUC (currently just in the form of OOB
+or SIMS) and then downloads it to a local cache to be served from instead.
+
+This saves privacy leaks for senders (leaking their domains) and to some extent
+for receivers (who probably trust the MUC more than arbitraty participants in
+it).
+
+When a message is moderated, any associated media is deleted from the cache.
+
+This does not use Prosody's internal HTTP server and assumes you will serve the
+folder of static files somehow.
+
+Files which cannot be cached (due to HTTP errors or limits below) will be passed
+through still with the original URL unchanged by default.
+
+Configuration
+=============
+
+`muc_media_store_path` : the path on disk where to store media files
+`muc_media_public_base` : the base URI where you will serve the media
+`muc_media_max_size` : the maximum size of file to cache
+
+Compatibility
+=============
+
+Requires Prosody 13.0 or higher.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_cache_media/mod_muc_cache_media.lua	Sun Feb 22 01:08:33 2026 -0500
@@ -0,0 +1,405 @@
+local http = require "net.http"
+local resolver = require "net.resolvers.basic"
+local socket_url = require "socket.url"
+local st = require "util.stanza"
+local async = require "util.async"
+local promise = require "util.promise"
+local mime = require "mime"
+local lfs = require "lfs"
+local hashes = require "util.hashes"
+local jid = require "util.jid"
+
+local storage_path = module:get_option_string("muc_media_store_path", "/var/lib/prosody/muc-media")
+local public_base_url = module:get_option_string("muc_media_public_base", "https://cache.example.com")
+local max_size = module:get_option_number("muc_media_max_size", 10 * 1024 * 1024)
+local MAX_REDIRECTS = 5
+
+lfs.mkdir(storage_path)
+
+local mime_extensions = {
+	["image/jpeg"] = ".jpg",
+	["image/jpg"]  = ".jpg",
+	["image/png"]  = ".png",
+	["image/gif"]  = ".gif",
+	["image/webp"] = ".webp",
+	["image/avif"] = ".avif",
+	["video/mp4"]  = ".mp4",
+	["video/webm"] = ".webm",
+	["audio/mpeg"] = ".mp3",
+	["audio/ogg"]  = ".ogg",
+}
+
+local function extension_from_content_type(content_type)
+	if not content_type then return ".bin" end
+	local clean = content_type:match("^[^;]+")
+	clean = clean and clean:lower()
+	return mime_extensions[clean] or ".bin"
+end
+
+local function ipv4_to_number(ip)
+	local a,b,c,d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
+	if not a then return nil end
+	return (tonumber(a) << 24) | (tonumber(b) << 16) | (tonumber(c) << 8) | tonumber(d)
+end
+
+local function ipv4_in_range(ip, base, mask) return (ip & mask) == base end
+
+local function is_private_ipv4(ip)
+	local n = ipv4_to_number(ip)
+	if not n then return false end
+	if ipv4_in_range(n, 0x0A000000, 0xFF000000) then return true end
+	if ipv4_in_range(n, 0xAC100000, 0xFFF00000) then return true end
+	if ipv4_in_range(n, 0xC0A80000, 0xFFFF0000) then return true end
+	if ipv4_in_range(n, 0x7F000000, 0xFF000000) then return true end
+	if ipv4_in_range(n, 0xA9FE0000, 0xFFFF0000) then return true end
+	if ipv4_in_range(n, 0x00000000, 0xFF000000) then return true end
+	if ipv4_in_range(n, 0xE0000000, 0xF0000000) then return true end
+	return false
+end
+
+local function is_private_ipv6(ip)
+	ip = ip:lower()
+	if ip == "::1" then return true end
+	if ip:match("^fc") or ip:match("^fd") then return true end
+	if ip:match("^fe8") or ip:match("^fe9") or ip:match("^fea") or ip:match("^feb") then return true end
+	if ip == "::" then return true end
+	return false
+end
+
+local function is_safe_host(host)
+	if not host then return false end
+	host = host:lower()
+	if host == "localhost" then return false end
+	local records = socket_dns.getaddrinfo(host)
+	if not records then return false end
+	for _, r in ipairs(records) do
+		local ip = r.addr
+		if ip:match("^%d+%.%d+%.%d+%.%d+$") then
+			if is_private_ipv4(ip) then return false end
+		else
+			if is_private_ipv6(ip) then return false end
+		end
+	end
+	return true
+end
+
+local function sha256_file(path)
+	local f = io.open(path,"rb")
+	if not f then return nil end
+	-- TODO: incremental hash
+	local digest = hashes.sha256(f:read("*all"))
+	f:close()
+	return mime.b64(digest)
+end
+
+local function sha3_256_file(path)
+	local f = io.open(path,"rb")
+	if not f then return nil end
+	-- TODO: incremental hash
+	local digest = hashes.sha3_256(f:read("*all"))
+	f:close()
+	return mime.b64(digest)
+end
+
+local function file_size(path)
+	return lfs.attributes(path,"size") or 0
+end
+
+local function filename_from_url(url)
+	return url:match(".*/([^/?]+)$") or "file"
+end
+
+local function resolve_redirects(url)
+	return promise.new(function(resolve, reject)
+		local current_url = url
+		local redirects = 0
+
+		local function step()
+			if redirects > MAX_REDIRECTS then reject("too many redirects"); return end
+			http.request(current_url,{
+				method="HEAD";
+				redirect=false;
+			}, function(_, code, response)
+				if not code or code == 0 then reject("request failed"); return end
+				if code>=300 and code<400 and response.headers.location then
+					redirects = redirects + 1
+					local location = response.headers.location
+					local parsed = socket_url.parse(current_url)
+					if not location:match("^https?://") then
+						location = parsed.scheme.."://"..parsed.host..(parsed.port and ":"..parsed.port or "")..location
+					end
+					current_url = location
+					step()
+					return
+				end
+				resolve(current_url)
+			end)
+		end
+
+		step()
+	end)
+end
+
+local function all_safe(resolv, didnext, net, ip)
+	if didnext and not ip then
+		return promise.resolve(true)
+	end
+
+	if not didnext or (net == "tcp4" and not is_private_ipv4(ip))
+		or (net == "tcp6" and not is_private_ipv6(ip)) then
+		return promise.new(function(resolve)
+			resolv:next(function(net,ip,port,extra)
+				all_safe(resolv, true, net, ip):next(resolve)
+			end)
+		end)
+	else
+		return promise.resolve(false)
+	end
+end
+
+local function secure_download_to_disk(original_url)
+	return resolve_redirects(original_url):next(function(final_url)
+		local parsed = socket_url.parse(final_url)
+		if not parsed or (parsed.scheme~="http" and parsed.scheme~="https") then return promise.reject("invalid scheme") end
+
+		return all_safe(resolver.new(parsed.host, 443, "tcp", { servername = parsed.host })):next(function(is_all_safe)
+			if not is_all_safe then return promise.reject("no safe IP") end
+
+			local tmpname = storage_path.."/tmp_"..tostring(math.random(1e9))
+			local file = io.open(tmpname,"wb")
+			if not file then return promise.reject("cannot open temp file") end
+
+			return promise.new(function(resolve, reject)
+				http.request(final_url,{
+					method = "GET";
+					redirect = false;
+					body_size_limit = max_size;
+					streaming_handler = function(r) return file, function() file:close(); file = nil end end;
+				}, function(_, code, response)
+					if code ~= 200 then
+						if file then file:close() end
+						os.remove(tmpname)
+						reject("bad status")
+						return
+					end
+					if file then
+						file:write(response.body)
+						file:close()
+					end
+					resolve({ tmpname, response.headers["content-type"] })
+				end)
+			end)
+		end)
+	end)
+end
+
+module:hook("muc-broadcast-message", function(event)
+	local stanza = event.stanza
+	if stanza.attr.type ~= "groupchat" then return end
+
+	local url_map = {}
+
+	-- Collect OOB URLs
+	for oob in stanza:childtags("x","jabber:x:oob") do
+		local url_tag = oob:get_child("url")
+		if url_tag then
+			url_map[url_tag:get_text()] = {}
+		end
+	end
+
+	-- Collect SIMS URLs
+	for ref in stanza:childtags("reference","urn:xmpp:reference:0") do
+		for ms in ref:childtags("media-sharing","urn:xmpp:sims:1") do
+			local sources = ms:get_child("sources")
+			if sources then
+				for sref in sources:childtags("reference","urn:xmpp:reference:0") do
+					local uri = sref.attr.uri
+					if uri then url_map[uri] = {} end
+				end
+			end
+		end
+	end
+
+	-- Cache each URL
+	for original_url,_ in pairs(url_map) do
+		local result, err = async.wait_for(secure_download_to_disk(original_url))
+		local tmp_path, content_type = result and result[1], result and result[2]
+		if not err and tmp_path then
+			local sha2 = sha256_file(tmp_path)
+			local sha3 = sha3_256_file(tmp_path)
+			local size = file_size(tmp_path)
+			local name = filename_from_url(original_url)
+			if sha2 then
+				local ext = extension_from_content_type(content_type)
+				local final_name = sha2:gsub("+","-"):gsub("/","_"):gsub("=","")..ext
+				local final_path = storage_path.."/"..final_name
+				if not lfs.attributes(final_path) then
+					os.rename(tmp_path, final_path)
+				else
+					os.remove(tmp_path)
+				end
+				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 }
+			else
+				os.remove(tmp_path)
+			end
+		end
+	end
+
+	-- Rewrite URLs and enrich/create SIMS
+	local original_body = stanza:get_child_text("body")
+	for original_url,data in pairs(url_map) do
+		if data.local_url then
+
+			for oob in stanza:childtags("x","jabber:x:oob") do
+				local url_tag = oob:get_child("url")
+				if url_tag and url_tag:get_text() == original_url then
+					oob:maptags(function(el)
+						if el.name == "url" then
+							return st.stanza("url"):text(data.local_url)
+						else
+							return el
+						end
+					end)
+				end
+			end
+
+			stanza:maptags(function(el)
+				if el.name == "body" then
+					return st.stanza("body"):text(el:get_text():gsub(original_url, data.local_url))
+				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
+					return el:maptags(function(subel)
+						local s, e = string.find(original_body, original_url)
+						if subel.name == "body" and math.abs(s - tonumber(subel.attr.start)) < 5 then
+							subel.attr["end"] = tostring((tonumber(subel.attr["end"]) - #original_url) + #data.local_url)
+						end
+						return subel
+					end)
+				else
+					return el
+				end
+			end)
+
+			local found = false
+			-- Enrich existing SIMS references
+			for ref in stanza:childtags("reference","urn:xmpp:reference:0") do
+				for ms in ref:childtags("media-sharing","urn:xmpp:sims:1") do
+					local sources = ms:get_child("sources")
+					if sources then
+						for sref in sources:childtags("reference","urn:xmpp:reference:0") do
+							if sref.attr.uri == original_url then
+								found = true
+								sref.attr.uri = data.local_url
+								local file_tag = ms:get_child("file","urn:xmpp:jingle:apps:file-transfer:5")
+								if not file_tag then
+									file_tag = st.stanza("file",{ xmlns="urn:xmpp:jingle:apps:file-transfer:5" })
+									ms:add_child(file_tag)
+								end
+								file_tag:maptags(function(el)
+									if el.name == "size" or el.name == "hash" then
+										return nil
+									else
+										return el
+									end
+								end)
+								-- Fill name
+								if not file_tag:get_child("name") then
+									file_tag:add_child(st.stanza("name"):text(data.name))
+								end
+								-- Fill size
+								if not file_tag:get_child("size") then
+									file_tag:add_child(st.stanza("size"):text(tostring(data.size)))
+								end
+								-- Fill SHA256 hash
+								local sha2_tag = nil
+								for htag in file_tag:childtags("hash","urn:xmpp:hashes:2") do
+									if htag.attr.algo=="sha2-256" then sha2_tag = htag end
+								end
+								if not sha2_tag then
+									file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha2-256" }):text(data.sha2))
+								end
+								if data.sha3 then
+									-- Fill SHA3-256 hash
+									local sha3_tag = nil
+									for htag in file_tag:childtags("hash","urn:xmpp:hashes:2") do
+										if htag.attr.algo=="sha3-256" then sha3_tag = htag end
+									end
+									if not sha3_tag then
+										file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha3-256" }):text(data.sha3))
+									end
+								end
+							end
+						end
+					end
+				end
+			end
+			-- Create new SIMS if none existed
+			if not found then
+				local ref = st.stanza("reference",{ xmlns="urn:xmpp:reference:0", type="data" })
+				local ms = st.stanza("media-sharing",{ xmlns="urn:xmpp:sims:1" })
+				local file_tag = st.stanza("file",{ xmlns="urn:xmpp:jingle:apps:file-transfer:5" })
+				file_tag:add_child(st.stanza("media-type"):text(data.content_type or "application/octet-stream"))
+				file_tag:add_child(st.stanza("name"):text(data.name))
+				file_tag:add_child(st.stanza("size"):text(tostring(data.size)))
+				file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha2-256" }):text(data.sha2))
+				if data.sha3 then file_tag:add_child(st.stanza("hash",{ xmlns="urn:xmpp:hashes:2", algo="sha3-256" }):text(data.sha3)) end
+				ms:add_child(file_tag)
+				local sources = st.stanza("sources")
+				sources:add_child(st.stanza("reference",{ xmlns="urn:xmpp:reference:0", type="data", uri=data.local_url }))
+				ms:add_child(sources)
+				ref:add_child(ms)
+				stanza:add_child(ref)
+			end
+		end
+	end
+end, 100)
+
+local archive = module:open_store("muc_log", "archive");
+
+module:hook("muc-moderate-message", function(event)
+	local room = event.room
+	local stanza_id = event.stanza_id
+	if not room or not stanza_id then return end
+
+	local room_node = jid.split(room.jid);
+
+	-- Find message by stanza_id
+	local query = {
+		with = "message<groupchat";
+		ids = { stanza_id };
+	}
+
+	local items, err = archive:find(room_node, query)
+	if not items or err then return end
+
+	for id, stanza, when in items do
+		-- Extract SIMS hashes
+		for ref in stanza:childtags("reference", "urn:xmpp:reference:0") do
+			for ms in ref:childtags("media-sharing", "urn:xmpp:sims:1") do
+				local file_tag = ms:get_child("file", "urn:xmpp:jingle:apps:file-transfer:5")
+				if file_tag then
+					for hash_tag in file_tag:childtags("hash", "urn:xmpp:hashes:2") do
+						if hash_tag.attr.algo == "sha2-256" then
+							local sha2 = hash_tag:get_text()
+							if sha2 then
+								local prefix = sha2:gsub("+","-"):gsub("/","_"):gsub("=","")
+								-- Files are named: hash + extension
+								for file in lfs.dir(storage_path) do
+									if file:match("^" .. prefix) then
+										local path = storage_path .. "/" .. file
+										if lfs.attributes(path) then
+											os.remove(path)
+											module:log("debug",
+												"Deleted cached media %s for moderated message %s",
+												file, stanza_id
+											)
+										end
+									end
+								end
+							end
+						end
+					end
+				end
+			end
+		end
+	end
+end)