From 70438b193bfe6368a01c50b04bbdfe9400ca36b0 Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Sat, 23 May 2026 22:46:36 +0200 Subject: [PATCH 01/10] Prototype WOPI integration --- copyparty/__main__.py | 7 ++++ copyparty/cfg.py | 1 + copyparty/httpcli.py | 76 ++++++++++++++++++++++++++++++++++++++++ copyparty/web/browser.js | 5 ++- 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 51ceefab..604c0210 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1568,6 +1568,12 @@ def add_opds(ap): ap2.add_argument("--opds-exts", metavar="T,T", type=u, default="epub,cbz,pdf", help="file formats to list in OPDS feeds; leave empty to show everything (volflag=opds_exts)") +def add_wopi(ap): + ap2 = ap.add_argument_group("WOPI options") + ap2.add_argument("--wopi", action="store_true", help="enable WOPI -- allows for integrating with office suites (volflag=wopi)") + ap2.add_argument("--wopi-path", action="store_true", default="wopi", help="where to expose WOPI (needs to start with 'wopi'); defaults to /wopi/ (volflag=wopi-path)") + + def add_handlers(ap): ap2 = ap.add_argument_group("handlers (see --help-handlers)") ap2.add_argument("--on404", metavar="PY", type=u, action="append", help="\033[34mREPEATABLE:\033[0m handle 404s by executing \033[33mPY\033[0m file") @@ -2045,6 +2051,7 @@ def run_argparse( add_tftp(ap) add_smb(ap) add_opds(ap) + add_wopi(ap) add_safety(ap) add_salt(ap, fk_salt, dk_salt, ah_salt) add_optouts(ap) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index d4c163bb..a45fa780 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -240,6 +240,7 @@ flagcats = { "gz": "allows server-side gzip compression of uploads with ?gz", "xz": "allows server-side lzma compression of uploads with ?xz", "pk": "forces server-side compression, optional arg: xz,9", + "wopi": "enable WOPI support for integrating with online office suites", }, "upload rules": { "apnd_who=dw": "who can append? (aw/dw/w/no)", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index e4cd6a36..66bcd48d 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1413,6 +1413,8 @@ class HttpCli(object): self.tx_404() return False + elif self.vpath.startswith("wopi"): + return self.tx_wopi() if "cf_challenge" in self.uparam: self.reply(self.j2s("cf").encode("utf-8", "replace")) @@ -1526,6 +1528,47 @@ class HttpCli(object): return self.tx_browser() + def tx_wopi(self) -> bool: + path = self.vpath.split('/') + full_path = "/mnt/tmp/" + path[2] + + if self.do_log: + self.log("WOPI GET %s" % (path[2])) + + if path[1] == "files": + if len(path) > 3 and path[3] == "contents": + return self.tx_file("oh_f", full_path) + else: + file_info = { + "BaseFileName": path[2], + "OwnerId": self.uname, + "Size": os.path.getsize(full_path), + "UserId": self.uname, + "UserFriendlyName": self.uname, + "UserCanWrite": True, + # "UserCanNotWriteRelative": False, + # "PostMessageOrigin": , + # "HidePrintOption": , + # "DisablePrint": , + # "HideSaveOption": , + # "HideExportOption": , + # "HideRepairOption": , + # "DisableExport": , + # "DisableCopy": , + "EnableOwnerTermination": True, + "LastModifiedTime": + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))), + # "IsUserLocked": , + # "IsUserRestricted": , + # "SupportsRename": True, + # "UserCanRename": True, + } + ret = json.dumps(file_info).encode("utf-8", "replace") + self.reply(ret, 200, "application/json; charset=utf-8") + return True + + return self.tx_404(True) + def tx_rss(self) -> bool: if self.do_log: self.log("RSS %s @%s" % (self.req, self.uname)) @@ -3149,6 +3192,9 @@ class HttpCli(object): except: raise Pebkac(400, "you must supply a content-length for binary POST") + if self.vpath.startswith("wopi"): + return self.rx_wopi() + try: chashes = self.headers["x-up2k-hash"].split(",") wark = self.headers["x-up2k-wark"] @@ -3360,6 +3406,36 @@ class HttpCli(object): self.reply(b"thank") return True + def rx_wopi(self) -> bool: + path = self.vpath.split('/') + full_path = "/mnt/tmp/" + path[2] + + if self.do_log: + self.log("WOPI POST %s" % (path[2])) + + if path[1] == "files": + if len(path) > 3 and path[3] == "contents": + reader, _ = self.get_body_reader() + buf = b"" + for rbuf in reader: + buf += rbuf + if not rbuf or len(buf) >= 32768: + break + + if buf: + with open(full_path, "wb") as file: + file.write(buf) + + mod_time = { + "LastModifiedTime": + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))) + } + ret = json.dumps(mod_time).encode("utf-8", "replace") + self.reply(ret, 200, "application/json; charset=utf-8") + return True + + return self.tx_404(True) + def handle_chpw(self) -> bool: assert self.parser # !rm if self.args.usernames: diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 6622c5c1..4c214056 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -7665,7 +7665,10 @@ var treectl = (function () { tn.href = addq(tn.href, 'v'); } - if (tn.lead == '-') + if (["ods", "odt", "odp"].includes(tn.ext)) { + tn.lead = '📄'; + } else if (tn.lead == '-') tn.lead = '-txt-'; From a5f3c61958bfd8d6c56c6df8c4980e7429fff05d Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Sat, 30 May 2026 23:25:18 +0200 Subject: [PATCH 02/10] Don't hardcode paths, but rethink the whole file flow so it brokey --- copyparty/httpcli.py | 76 ++++++++++++++++++++++++---------------- copyparty/web/browser.js | 19 +++++++--- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 66bcd48d..ed5b5ea8 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1413,8 +1413,6 @@ class HttpCli(object): self.tx_404() return False - elif self.vpath.startswith("wopi"): - return self.tx_wopi() if "cf_challenge" in self.uparam: self.reply(self.j2s("cf").encode("utf-8", "replace")) @@ -1526,21 +1524,35 @@ class HttpCli(object): if "rss" in self.uparam: return self.tx_rss() + if "wopi" in self.uparam: + return self.tx_wopi() + return self.tx_browser() def tx_wopi(self) -> bool: + self.log("%s" % self.uparam.get("wopi")) + return False + # https://' + location.hostname + ':9980/browser/4610258811/cool.html?WOPISrc=' + location.origin + '/wopi/files' + top + bhref + path = self.vpath.split('/') - full_path = "/mnt/tmp/" + path[2] - - if self.do_log: - self.log("WOPI GET %s" % (path[2])) if path[1] == "files": - if len(path) > 3 and path[3] == "contents": + if path[-1] == "contents": + vfs, _ = self.asrv.vfs.get("/".join(path[2:-1]), self.uname, False, True) + full_path = vfs.realpath + "/" + "/".join(path[2:-1]) + + if self.do_log: + self.log("WOPI GET 'contents': %s" % (full_path)) + return self.tx_file("oh_f", full_path) else: + vfs, _ = self.asrv.vfs.get("/".join(path[2:]), self.uname, False, True) + full_path = vfs.realpath + "/" + "/".join(path[2:]) + + if self.do_log: + self.log("WOPI GET 'file_info': %s" % (full_path)) + file_info = { - "BaseFileName": path[2], + "BaseFileName": path[-1], "OwnerId": self.uname, "Size": os.path.getsize(full_path), "UserId": self.uname, @@ -1557,7 +1569,10 @@ class HttpCli(object): # "DisableCopy": , "EnableOwnerTermination": True, "LastModifiedTime": - time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))), + time.strftime( + "%Y-%m-%dT%H:%M:%SZ", + time.gmtime(os.path.getmtime(full_path)) + ), # "IsUserLocked": , # "IsUserRestricted": , # "SupportsRename": True, @@ -3408,31 +3423,32 @@ class HttpCli(object): def rx_wopi(self) -> bool: path = self.vpath.split('/') - full_path = "/mnt/tmp/" + path[2] - if self.do_log: - self.log("WOPI POST %s" % (path[2])) + if path[1] == "files" and path[-1] == "contents": + vfs, _ = self.asrv.vfs.get("/".join(path[2:-1]), self.uname, False, True) + full_path = vfs.realpath + "/" + "/".join(path[2:-1]) - if path[1] == "files": - if len(path) > 3 and path[3] == "contents": - reader, _ = self.get_body_reader() - buf = b"" - for rbuf in reader: - buf += rbuf - if not rbuf or len(buf) >= 32768: - break + if self.do_log: + self.log("WOPI POST 'contents': %s" % (full_path)) - if buf: - with open(full_path, "wb") as file: - file.write(buf) + reader, _ = self.get_body_reader() + buf = b"" + for rbuf in reader: + buf += rbuf + if not rbuf or len(buf) >= 32768: + break - mod_time = { - "LastModifiedTime": - time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))) - } - ret = json.dumps(mod_time).encode("utf-8", "replace") - self.reply(ret, 200, "application/json; charset=utf-8") - return True + if buf: + with open(full_path, "wb") as file: + file.write(buf) + + mod_time = { + "LastModifiedTime": + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))) + } + ret = json.dumps(mod_time).encode("utf-8", "replace") + self.reply(ret, 200, "application/json; charset=utf-8") + return True return self.tx_404(True) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 4c214056..1aa69e9d 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -7665,13 +7665,24 @@ var treectl = (function () { tn.href = addq(tn.href, 'v'); } - if (["ods", "odt", "odp"].includes(tn.ext)) { - tn.lead = '📄'; - } else if (tn.lead == '-') + // https://en.wikipedia.org/wiki/OpenDocument + // https://help.collaboraoffice.com/latest/en-US/text/shared/guide/ms_user.html + var office_formats = [ + "odt", "fodt", "doc", "docx", + "ods", "fods", "xls", "xlsx", + "odp", "fopd", "ppt", "pps", "pptx", + "odg", "fodg", + "odf", + ]; + + if (office_formats.includes(tn.ext)) { + tn.lead = '📄'; + } else if (tn.lead == '-') { tn.lead = '-txt-'; + } var cl = /\.PARTIAL$/.exec(fname) ? ' class="fade"' : '', ln = ['' + tn.lead + ' bool: - self.log("%s" % self.uparam.get("wopi")) - return False - # https://' + location.hostname + ':9980/browser/4610258811/cool.html?WOPISrc=' + location.origin + '/wopi/files' + top + bhref + + def tx_wopi_api(self) -> bool: path = self.vpath.split('/') - if path[1] == "files": - if path[-1] == "contents": - vfs, _ = self.asrv.vfs.get("/".join(path[2:-1]), self.uname, False, True) - full_path = vfs.realpath + "/" + "/".join(path[2:-1]) + if "files" in path: + real_path = self.conn.hsrv.wopi_files[path[2]] + vfs, _ = self.asrv.vfs.get(real_path, self.uname, False, True) + full_path = vfs.realpath + "/" + real_path + if "contents" in path: if self.do_log: self.log("WOPI GET 'contents': %s" % (full_path)) return self.tx_file("oh_f", full_path) else: - vfs, _ = self.asrv.vfs.get("/".join(path[2:]), self.uname, False, True) - full_path = vfs.realpath + "/" + "/".join(path[2:]) - if self.do_log: self.log("WOPI GET 'file_info': %s" % (full_path)) file_info = { - "BaseFileName": path[-1], + "BaseFileName": real_path.split("/")[-1], "OwnerId": self.uname, "Size": os.path.getsize(full_path), "UserId": self.uname, @@ -1567,7 +1570,7 @@ class HttpCli(object): # "HideRepairOption": , # "DisableExport": , # "DisableCopy": , - "EnableOwnerTermination": True, + # "EnableOwnerTermination": True, "LastModifiedTime": time.strftime( "%Y-%m-%dT%H:%M:%SZ", @@ -1582,7 +1585,61 @@ class HttpCli(object): self.reply(ret, 200, "application/json; charset=utf-8") return True - return self.tx_404(True) + return self.tx_404() + + def tx_wopi(self) -> bool: + path = self.vpath + "/" + str(self.uparam["wopi"]) + file_key = self.gen_fk(2, self.args.fk_salt, path, 0, 0) + self.conn.hsrv.wopi_files[file_key] = path + + try: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + ctx.verify_mode = ssl.CERT_NONE if self.args.wopi_self_signed else ssl.CERT_REQUIRED + discovery = urllib.request.urlopen(self.args.wopi_client + "/hosting/discovery", context=ctx) + response = ET.fromstring(discovery.read()) + ext = path.split('.')[-1] + wopi_url = response.find(".//action[@ext='%s'][@urlsrc]" % ext).get("urlsrc") + favicon_url = response.find(".//action[@ext='%s'].." % ext).get("favIconUrl") + url = wopi_url + "WOPISrc=https://" + self.host + "/wopi/files/" + file_key + except Exception as error: + self.log("Couldn't get urls from WOPI client: %s" % error) + return False + + + ret = [ + """\ + + + + + + + Load Collabora Online + + + + + + """ - % (favicon_url, url) + % (favicon_url, url, session_key) ] bret = "".join(ret).encode("utf-8", "replace") @@ -3480,8 +3503,12 @@ class HttpCli(object): def rx_wopi(self) -> bool: path = self.vpath.split('/') - if "files" in path and "contents" in path: - real_path = self.conn.hsrv.wopi_files[path[2]] + if ( + "files" in path and + "contents" in path and + self.conn.hsrv.wopi_files[self.uparam["access_token"]]["file_key"] in path + ): + real_path = self.conn.hsrv.wopi_files[self.uparam["access_token"]]["path"] vfs, _ = self.asrv.vfs.get(real_path, self.uname, False, True) full_path = vfs.realpath + "/" + real_path diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index ecaeaa70..b93087b3 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -243,7 +243,7 @@ class HttpSrv(object): Daemon(self.post_init, "hsrv-init2") if self.args.wopi: - self.wopi_files: dict[str, str] = {} + self.wopi_files: dict[str, dict[str, str]] = {} def post_init(self) -> None: try: From d4a18e8ac7ef270c6ca20b1a5499c9b84e62f32e Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:51:55 +0200 Subject: [PATCH 06/10] Remove the self-signed option Properly configure a reverse proxy/Collabora instead. --- copyparty/__main__.py | 3 +-- copyparty/cfg.py | 1 - copyparty/httpcli.py | 6 +----- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 2de35ead..1ddbdaae 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1575,8 +1575,7 @@ def add_opds(ap): def add_wopi(ap): ap2 = ap.add_argument_group("WOPI options") ap2.add_argument("--wopi", action="store_true", help="allows for integrating with office suites using WOPI (volflag=wopi)") - ap2.add_argument("--wopi-client", type=u, default="https://localhost:9980", help="where to find your WOPI client, this is what actually hosts e.g. Collabora Online") - ap2.add_argument("--wopi-self-signed", action="store_true", help="disable certificate verification of the WOPI client, only use with local use") + ap2.add_argument("--wopi-client", type=u, default="https://demo.eu.collaboraonline.com", help="where to find your WOPI client, this is what actually hosts e.g. Collabora Online") def add_handlers(ap): diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 0c5f6c4f..39b51ccb 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -397,7 +397,6 @@ flagcats = { "wopi": { "wopi": "enable WOPI support for integrating with online office suites", "wopi-client": "address of WOPI client, e.g. Collabora Online", - "wopi-self-signed": "disable certificate verification of the WOPI client, only use with local use" }, "opds": { "opds": "enable OPDS", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 750f5890..461ae50a 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -18,7 +18,6 @@ import time import uuid import urllib.request import urllib.parse -import ssl import xml.etree.ElementTree as ET from datetime import datetime from operator import itemgetter @@ -1606,10 +1605,7 @@ class HttpCli(object): } try: - ctx = ssl.create_default_context() - ctx.check_hostname = False if self.args.wopi_self_signed else True - ctx.verify_mode = ssl.CERT_NONE if self.args.wopi_self_signed else ssl.CERT_REQUIRED - discovery = urllib.request.urlopen(self.args.wopi_client + "/hosting/discovery", context=ctx) + discovery = urllib.request.urlopen(self.args.wopi_client + "/hosting/discovery") response = ET.fromstring(discovery.read()) ext = path.split('.')[-1] wopi_url = response.find(".//action[@ext='%s'][@urlsrc]" % ext).get("urlsrc") From 3b68f6895abf260bb033412af310b4e1574c81a9 Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:05:57 +0200 Subject: [PATCH 07/10] Fix files not fully saving Thought this was for chunking, but nope --- copyparty/httpcli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 461ae50a..aad7632b 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -3516,7 +3516,7 @@ class HttpCli(object): buf = b"" for rbuf in reader: buf += rbuf - if not rbuf or len(buf) >= 32768: + if not rbuf: break if buf: From f1fe9c64529fcc5f08769d4dc1d2b2f588f27532 Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:15:58 +0200 Subject: [PATCH 08/10] Clean up optional (unused) wopi checkFileInfo options --- copyparty/httpcli.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index aad7632b..c162ead7 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1567,25 +1567,12 @@ class HttpCli(object): "UserId": self.uname, "UserFriendlyName": self.uname, "UserCanWrite": True, - # "UserCanNotWriteRelative": False, - # "PostMessageOrigin": , - # "HidePrintOption": , - # "DisablePrint": , - # "HideSaveOption": , - # "HideExportOption": , - # "HideRepairOption": , - # "DisableExport": , - # "DisableCopy": , - # "EnableOwnerTermination": True, + "UserCanNotWriteRelative": True, "LastModifiedTime": time.strftime( "%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path)) ), - # "IsUserLocked": , - # "IsUserRestricted": , - # "SupportsRename": True, - # "UserCanRename": True, } ret = json.dumps(file_info).encode("utf-8", "replace") self.reply(ret, 200, "application/json; charset=utf-8") From 8304388d299de608cbb87b6bfb0e46447af4f8ce Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:47:39 +0200 Subject: [PATCH 09/10] Properly handle on disk changes Follows Collabora spec: https://sdk.collaboraonline.com/docs/advanced_integration.html#detecting-external-document-change --- copyparty/httpcli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index c162ead7..fbf21927 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -3499,6 +3499,11 @@ class HttpCli(object): if self.do_log: self.log("WOPI POST 'contents': %s" % (full_path)) + last_mod_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))) + if self.headers["x-cool-wopi-timestamp"] != last_mod_time: + self.reply(json.dumps({"COOLStatusCode": 1010}).encode("utf-8"), 409) + return True + reader, _ = self.get_body_reader() buf = b"" for rbuf in reader: @@ -3510,11 +3515,11 @@ class HttpCli(object): with open(full_path, "wb") as file: file.write(buf) - mod_time = { + new_mod_time = { "LastModifiedTime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))) } - ret = json.dumps(mod_time).encode("utf-8", "replace") + ret = json.dumps(new_mod_time).encode("utf-8", "replace") self.reply(ret, 200, "application/json; charset=utf-8") return True From b668b8d2f3d80e10ea2f60d6948da844707ebce0 Mon Sep 17 00:00:00 2001 From: Brandon Doornbos <41441504+brandon-doornbos@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:51:39 +0200 Subject: [PATCH 10/10] Set page title to document name --- copyparty/httpcli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index fbf21927..48bdee4d 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1610,7 +1610,7 @@ class HttpCli(object): - Loading... + %s