comparison mod_muc_cache_media/mod_muc_cache_media.lua @ 6414:48fca0914fcf

mod_muc_cache_media: simplify code with target_filter
author Stephen Paul Weber <singpolyma@singpolyma.net>
date Mon, 23 Feb 2026 12:41:36 -0500
parents c25b98d42ed0
children 420aaf113272
comparison
equal deleted inserted replaced
6413:0703ef9f8db8 6414:48fca0914fcf
10 local jid = require "util.jid" 10 local jid = require "util.jid"
11 11
12 local storage_path = module:get_option_string("muc_media_store_path", "/var/lib/prosody/muc-media") 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") 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) 14 local max_size = module:get_option_number("muc_media_max_size", 10 * 1024 * 1024)
15 local MAX_REDIRECTS = 5
16 15
17 lfs.mkdir(storage_path) 16 lfs.mkdir(storage_path)
18 17
19 local mime_extensions = { 18 local mime_extensions = {
20 ["image/jpeg"] = ".jpg", 19 ["image/jpeg"] = ".jpg",
64 if ip:match("^fe8") or ip:match("^fe9") or ip:match("^fea") or ip:match("^feb") then return true end 63 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 64 if ip == "::" then return true end
66 return false 65 return false
67 end 66 end
68 67
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) 68 local function sha256_file(path)
87 local f = io.open(path,"rb") 69 local f = io.open(path,"rb")
88 if not f then return nil end 70 if not f then return nil end
89 -- TODO: incremental hash 71 -- TODO: incremental hash
90 local digest = hashes.sha256(f:read("*all")) 72 local digest = hashes.sha256(f:read("*all"))
107 89
108 local function filename_from_url(url) 90 local function filename_from_url(url)
109 return url:match(".*/([^/?]+)$") or "file" 91 return url:match(".*/([^/?]+)$") or "file"
110 end 92 end
111 93
112 local function resolve_redirects(url) 94 local function secure_download_to_disk(url)
95 local parsed = socket_url.parse(url)
96 if not parsed or (parsed.scheme~="http" and parsed.scheme~="https") then return promise.reject("invalid scheme") end
97
98 local tmpname = storage_path.."/tmp_"..tostring(math.random(1e9))
99 local file = io.open(tmpname,"wb")
100 if not file then return promise.reject("cannot open temp file") end
101
113 return promise.new(function(resolve, reject) 102 return promise.new(function(resolve, reject)
114 local current_url = url 103 http.request(url,{
115 local redirects = 0 104 method = "GET";
116 105 body_size_limit = max_size;
117 local function step() 106 streaming_handler = function(r) return file, function() file:close(); file = nil end end;
118 if redirects > MAX_REDIRECTS then reject("too many redirects"); return end 107 target_filter = function(net, ip, port, extra)
119 http.request(current_url,{ 108 return (net == "tcp4" and not is_private_ipv4(ip)) or (net == "tcp6" and not is_private_ipv6(ip))
120 method="HEAD"; 109 end;
121 redirect=false; 110 }, function(_, code, response)
122 }, function(_, code, response) 111 if code ~= 200 then
123 if not code or code == 0 then reject("request failed"); return end 112 if file then file:close() end
124 if code>=300 and code<400 and response.headers.location then 113 os.remove(tmpname)
125 redirects = redirects + 1 114 reject("bad status")
126 local location = response.headers.location 115 return
127 local parsed = socket_url.parse(current_url) 116 end
128 if not location:match("^https?://") then 117 if file then
129 location = parsed.scheme.."://"..parsed.host..(parsed.port and ":"..parsed.port or "")..location 118 file:write(response.body)
130 end 119 file:close()
131 current_url = location 120 end
132 step() 121 resolve({ tmpname, response.headers["content-type"] })
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) 122 end)
193 end) 123 end)
194 end 124 end
195 125
196 module:hook("muc-broadcast-message", function(event) 126 module:hook("muc-broadcast-message", function(event)