diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 7fdb81ec..1ddbdaae 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1572,6 +1572,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="allows for integrating with office suites using WOPI (volflag=wopi)") + 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): 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") @@ -2080,6 +2086,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..39b51ccb 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -394,6 +394,10 @@ flagcats = { "og_no_head": "you want to add tags manually with og_tpl", "og_ua": "if defined: only send OG html if useragent matches this regex", }, + "wopi": { + "wopi": "enable WOPI support for integrating with online office suites", + "wopi-client": "address of WOPI client, e.g. Collabora Online", + }, "opds": { "opds": "enable OPDS", "opds_exts": "file formats to list in OPDS feeds; leave empty to show everything", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 308f8a34..87c952d7 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -16,6 +16,9 @@ import sys import threading # typechk import time import uuid +import urllib.request +import urllib.parse +import xml.etree.ElementTree as ET from datetime import datetime from operator import itemgetter @@ -798,6 +801,12 @@ class HttpCli(object): else: self.log("unknown username: %r" % (idp_usr,), 1) + try: + if self.args.wopi: + self.uname = self.conn.hsrv.wopi_files.get(self.uparam.get("access_token")).get("uname") + except: + pass + if self.args.have_ipu_or_ipr: if self.args.ipu and (self.uname == "*" or self.args.ao_ipu_wins): self.uname = self.conn.ipu_iu[self.conn.ipu_nm.map(self.ip)] @@ -1525,8 +1534,120 @@ class HttpCli(object): if "rss" in self.uparam: return self.tx_rss() + if self.args.wopi: + if "wopi" in self.uparam: + return self.tx_wopi() + + if self.vpath.startswith("wopi"): + return self.tx_wopi_api() + return self.tx_browser() + def tx_wopi_api(self) -> bool: + path = self.vpath.split('/') + + if "files" 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 + + 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: + if self.do_log: + self.log("WOPI GET 'file_info': %s" % (full_path)) + + file_info = { + "BaseFileName": real_path.split("/")[-1], + "OwnerId": self.uname, + "Size": os.path.getsize(full_path), + "UserId": self.uname, + "UserFriendlyName": self.uname, + "UserCanWrite": True, + "UserCanNotWriteRelative": True, + "LastModifiedTime": + time.strftime( + "%Y-%m-%dT%H:%M:%SZ", + time.gmtime(os.path.getmtime(full_path)) + ), + } + ret = json.dumps(file_info).encode("utf-8", "replace") + self.reply(ret, 200, "application/json; charset=utf-8") + return True + + return self.tx_404() + + def tx_wopi(self) -> bool: + path = self.vpath + "/" + str(self.uparam["wopi"]) + session_salt = ub64enc(os.urandom(64)).decode("utf-8") + session_key = self.gen_fk(2, session_salt, self.uname, 0, 0) + file_key = self.gen_fk(2, self.args.fk_salt, path, 0, 0) + self.conn.hsrv.wopi_files[session_key] = { + "uname": self.uname, + "file_key": file_key, + "path": path, + } + + try: + 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") + favicon_url = response.find(".//action[@ext='%s'].." % ext).get("favIconUrl") + url = wopi_url + urllib.parse.quote("WOPISrc=https://" + self.host + "/wopi/files/" + file_key, safe="=") + except Exception as error: + self.log("Couldn't get urls from WOPI client: %s" % error) + return False + + ret = [ + """\ + + + + + + + %s + + + +
+
+ + +
+
+ + + + + + +""" + % (favicon_url, self.uparam["wopi"], url, session_key) + ] + + bret = "".join(ret).encode("utf-8", "replace") + self.reply(bret, 200, "text/html; charset=utf-8") + return True + def tx_rss(self) -> bool: if self.do_log: self.log("RSS %s @%s" % (self.req, self.uname)) @@ -3149,6 +3270,9 @@ class HttpCli(object): except: raise Pebkac(400, "you must supply a content-length for binary POST") + if self.args.wopi and self.vpath.startswith("wopi"): + return self.rx_wopi() + try: chashes = self.headers["x-up2k-hash"].split(",") wark = self.headers["x-up2k-wark"] @@ -3360,6 +3484,47 @@ class HttpCli(object): self.reply(b"thank") return True + def rx_wopi(self) -> bool: + path = self.vpath.split('/') + + 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 + + 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: + buf += rbuf + if not rbuf: + break + + if buf: + with open(full_path, "wb") as file: + file.write(buf) + + new_mod_time = { + "LastModifiedTime": + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(full_path))) + } + ret = json.dumps(new_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/httpsrv.py b/copyparty/httpsrv.py index db81725b..b93087b3 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -242,6 +242,9 @@ class HttpSrv(object): if (HAVE_PIL or HAVE_VIPS or HAVE_FFMPEG) and not self.args.no_thumb: Daemon(self.post_init, "hsrv-init2") + if self.args.wopi: + self.wopi_files: dict[str, dict[str, str]] = {} + def post_init(self) -> None: try: x = self.broker.ask("thumbsrv.getcfg") diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index ee71d781..b3cf0527 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -7665,10 +7665,24 @@ var treectl = (function () { tn.href = addq(tn.href, 'v'); } - 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 + '