diff options
-rw-r--r-- | CHANGES | 1 | ||||
-rw-r--r-- | doc/doap.xml | 9 | ||||
-rw-r--r-- | plugins/mod_http_file_share.lua | 191 | ||||
-rw-r--r-- | spec/scansion/http_upload.scs | 26 | ||||
-rw-r--r-- | spec/scansion/prosody.cfg.lua | 2 |
5 files changed, 229 insertions, 0 deletions
@@ -20,6 +20,7 @@ TRUNK - mod_external_services (XEP-0215) - util.error for encapsulating errors - MUC: support for XEP-0421 occupant identifiers +- mod_http_file_share: File sharing via HTTP (XEP-0363) 0.11.0 ====== diff --git a/doc/doap.xml b/doc/doap.xml index f0538580..277aa5d1 100644 --- a/doc/doap.xml +++ b/doc/doap.xml @@ -617,6 +617,15 @@ </implements> <implements> <xmpp:SupportedXep> + <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0363.html"/> + <xmpp:version>1.0.0</xmpp:version> + <xmpp:status>complete</xmpp:status> + <xmpp:since>0.12.0</xmpp:since> + <xmpp:note>mod_http_file_share</xmpp:note> + </xmpp:SupportedXep> + </implements> + <implements> + <xmpp:SupportedXep> <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0368.html"/> <xmpp:version>1.1.0</xmpp:version> <xmpp:status>partial</xmpp:status> diff --git a/plugins/mod_http_file_share.lua b/plugins/mod_http_file_share.lua new file mode 100644 index 00000000..0cb610d9 --- /dev/null +++ b/plugins/mod_http_file_share.lua @@ -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; + } + }); diff --git a/spec/scansion/http_upload.scs b/spec/scansion/http_upload.scs new file mode 100644 index 00000000..a683483e --- /dev/null +++ b/spec/scansion/http_upload.scs @@ -0,0 +1,26 @@ +[Client] Romeo + password: password + jid: filesharingenthusiast@localhost/krxLaE3s + +----- + +Romeo connects + +Romeo sends: + <iq to='upload.localhost' type='get' id='932c02fe-4461-4ad4-9c85-54863294b4dc' xml:lang='en'> + <request content-type='text/plain' filename='verysmall.dat' xmlns='urn:xmpp:http:upload:0' size='5'/> + </iq> + +Romeo receives: + <iq id='932c02fe-4461-4ad4-9c85-54863294b4dc' from='upload.localhost' type='result'> + <slot xmlns='urn:xmpp:http:upload:0'> + <get url='{scansion:any}'/> + <put url='{scansion:any}'> + <header name='Authorization'></header> + </put> + </slot> + </iq> + +Romeo disconnects + +# recording ended on 2021-01-27T22:10:46Z diff --git a/spec/scansion/prosody.cfg.lua b/spec/scansion/prosody.cfg.lua index d147db54..ca28b6ba 100644 --- a/spec/scansion/prosody.cfg.lua +++ b/spec/scansion/prosody.cfg.lua @@ -131,3 +131,5 @@ Component "conference.localhost" "muc" Component "pubsub.localhost" "pubsub" storage = "memory" + +Component "upload.localhost" "http_file_share" |