Diff

plugins/mod_http_file_share.lua @ 11309:b59aed75dc5e

mod_http_file_share: Let's write another XEP-0363 implementation This variant is meant to improve upon mod_http_upload in some ways: * Handle files much of arbitrary size efficiently * Allow GET and PUT URLs to be different * Remember Content-Type sent by client * Avoid dependency on mod_http_files * Built-in way to delegate storage to another httpd
author Kim Alvefur <zash@zash.se>
date Tue, 26 Jan 2021 03:19:17 +0100
child 11310:d1a0f2e918c0
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_http_file_share.lua	Tue Jan 26 03:19:17 2021 +0100
@@ -0,0 +1,191 @@
+-- Prosody IM
+-- Copyright (C) 2021 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- XEP-0363: HTTP File Upload
+-- Again, from the top!
+
+local t_insert = table.insert;
+local jid = require "util.jid";
+local st = require "util.stanza";
+local url = require "socket.url";
+local dm = require "core.storagemanager".olddm;
+local jwt = require "util.jwt";
+local errors = require "util.error";
+
+local namespace = "urn:xmpp:http:upload:0";
+
+module:depends("http");
+module:depends("disco");
+
+module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload"));
+module:add_feature(namespace);
+
+local uploads = module:open_store("uploads", "archive");
+-- id, <request>, time, owner
+
+local secret = module:get_option_string(module.name.."_secret", require"util.id".long());
+
+function may_upload(uploader, filename, filesize, filetype) -- > boolean, error
+	-- TODO authz
+	return true;
+end
+
+function get_authz(uploader, filename, filesize, filetype, slot)
+	return "Bearer "..jwt.sign(secret, {
+		sub = uploader;
+		filename = filename;
+		filesize = filesize;
+		filetype = filetype;
+		slot = slot;
+		exp = os.time()+300;
+	});
+end
+
+function get_url(slot, filename)
+	local base_url = module:http_url();
+	local slot_url = url.parse(base_url);
+	slot_url.path = url.parse_path(slot_url.path or "/");
+	t_insert(slot_url.path, slot);
+	if filename then
+		t_insert(slot_url.path, filename);
+		slot_url.path.is_directory = false;
+	else
+		slot_url.path.is_directory = true;
+	end
+	slot_url.path = url.build_path(slot_url.path);
+	return url.build(slot_url);
+end
+
+function handle_slot_request(event)
+	local stanza, origin = event.stanza, event.origin;
+
+	local request = st.clone(stanza.tags[1], true);
+	local filename = request.attr.filename;
+	local filesize = tonumber(request.attr.size);
+	local filetype = request.attr["content-type"];
+	local uploader = jid.bare(stanza.attr.from);
+
+	local may, why_not = may_upload(uploader, filename, filesize, filetype);
+	if not may then
+		origin.send(st.error_reply(stanza, why_not));
+		return true;
+	end
+
+	local slot, storage_err = errors.coerce(uploads:append(nil, nil, request, os.time(), uploader))
+	if not slot then
+		origin.send(st.error_reply(stanza, storage_err));
+		return true;
+	end
+
+	local authz = get_authz(uploader, filename, filesize, filetype, slot);
+	local slot_url = get_url(slot, filename);
+	local upload_url = slot_url;
+
+	local reply = st.reply(stanza)
+		:tag("slot", { xmlns = namespace })
+			:tag("get", { url = slot_url }):up()
+			:tag("put", { url = upload_url })
+				:text_tag("header", authz, {name="Authorization"})
+		:reset();
+
+	origin.send(reply);
+	return true;
+end
+
+function handle_upload(event, path) -- PUT /upload/:slot
+	local request = event.request;
+	local authz = request.headers.authorization;
+	if not authz or not authz:find"^Bearer ." then
+		return 403;
+	end
+	local authed, upload_info = jwt.verify(secret, authz:match("^Bearer (.*)"));
+	if not (authed and type(upload_info) == "table" and type(upload_info.exp) == "number") then
+		return 401;
+	end
+	if upload_info.exp < os.time() then
+		return 410;
+	end
+	if not path or upload_info.slot ~= path:match("^[^/]+") then
+		return 400;
+	end
+
+	local filename = dm.getpath(upload_info.slot, module.host, module.name, nil, true);
+
+	if not request.body_sink then
+		local fh, err = errors.coerce(io.open(filename.."~", "w"));
+		if not fh then
+			return err;
+		end
+		request.body_sink = fh;
+		if request.body == false then
+			return true;
+		end
+	end
+
+	if request.body then
+		local written, err = errors.coerce(request.body_sink:write(request.body));
+		if not written then
+			return err;
+		end
+		request.body = nil;
+	end
+
+	if request.body_sink then
+		local uploaded, err = errors.coerce(request.body_sink:close());
+		if uploaded then
+			assert(os.rename(filename.."~", filename));
+			return 201;
+		else
+			assert(os.remove(filename.."~"));
+			return err;
+		end
+	end
+
+end
+
+function handle_download(event, path) -- GET /uploads/:slot+filename
+	local request, response = event.request, event.response;
+	local slot_id = path:match("^[^/]+");
+	-- TODO cache
+	local slot, when = errors.coerce(uploads:get(nil, slot_id));
+	if not slot then
+		module:log("debug", "uploads:get(%q) --> not-found, %s", slot_id, when);
+		return 404;
+	end
+	module:log("debug", "uploads:get(%q) --> %s, %d", slot_id, slot, when);
+	local last_modified = os.date('!%a, %d %b %Y %H:%M:%S GMT', when);
+	if request.headers.if_modified_since == last_modified then
+		return 304;
+	end
+	local filename = dm.getpath(slot_id, module.host, module.name);
+	local handle, ferr = errors.coerce(io.open(filename));
+	if not handle then
+		return ferr or 410;
+	end
+	response.headers.last_modified = last_modified;
+	response.headers.content_length = slot.attr.size;
+	response.headers.content_type = slot.attr["content-type"];
+	response.headers.content_disposition = string.format("attachment; filename=%q", slot.attr.filename);
+
+	response.headers.cache_control = "max-age=31556952, immutable";
+	response.headers.content_security_policy =  "default-src 'none'; frame-ancestors 'none';"
+
+	return response:send_file(handle);
+	-- TODO
+	-- Set security headers
+end
+
+-- TODO periodic cleanup job
+
+module:hook("iq-get/host/urn:xmpp:http:upload:0:request", handle_slot_request);
+
+module:provides("http", {
+		streaming_uploads = true;
+		route = {
+			["PUT /*"] = handle_upload;
+			["GET /*"] = handle_download;
+		}
+	});