diff --git a/copyparty/__main__.py b/copyparty/__main__.py index de2ce706..ce8e13d5 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1577,6 +1577,17 @@ 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 integration with office suites using WOPI") + ap2.add_argument("--wopi-api", metavar="URL", type=u, default="", help="URL that the WOPI-client should use to communicate with copyparty; default is same as user's webbrowser. Example: [\033[32mhttps://party.example.com/\033[0m]") + ap2.add_argument("--wopi-url", metavar="URL", type=u, default="", help="URL to your WOPI client; the host of e.g. Collabora Online. Example: [\033[32mhttps://code.example.com/\033[0m]") + ap2.add_argument("--wopi-crt", metavar="TXT", type=u, default="", help="if \033[33m--wopi-url\033[0m is selfsigned: path to ca.pem or cert.pem to expect/verify (can be [\033[32mno\033[0m] for full-yolo)") + ap2.add_argument("--wopi-crt-icn", action="store_true", help="if \033[33m--wopi-url\033[0m is selfsigned: ignore the CN (server ip/name) in cert") + ap2.add_argument("--wopi-ttl", metavar="SEC", type=int, default=1800, help="session lifetime; allow editing for this many seconds (default is 30 min)") + ap2.add_argument("--wopi-wdel", action="store_true", help="require permissions read+write+delete for writing to file; default is just read+write") + + 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") @@ -2093,6 +2104,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/httpcli.py b/copyparty/httpcli.py index fd586f1c..0ec4a6d8 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -30,7 +30,7 @@ try: except: pass -from .__init__ import ANYWIN, RES, RESM, TYPE_CHECKING, EnvParams, unicode +from .__init__ import ANYWIN, PY2, RES, RESM, TYPE_CHECKING, EnvParams, unicode from .__version__ import S_VERSION from .authsrv import LEELOO_DALLAS, VFS # typechk from .bos import bos @@ -149,6 +149,11 @@ if True: # pylint: disable=using-constant-test if TYPE_CHECKING: from .httpconn import HttpConn +if PY2: + from urllib2 import urlopen +else: + from urllib.request import urlopen + if not hasattr(socket, "AF_UNIX"): setattr(socket, "AF_UNIX", -9001) @@ -813,6 +818,22 @@ class HttpCli(object): if not ipr[self.uname].map(self.ip): self.log("username [%s] rejected by --ipr" % (self.uname,), 3) self.uname = "*" + if self.args.wopi and "access_token" in self.uparam: + wopi_a = self.uparam["access_token"] + try: + wopi_f = self.conn.hsrv.wopi_files[wopi_a] + if wopi_f["expires"] < time.time(): + raise Exception("expired") + uname = wopi_f["uname"] + self.asrv.vfs.get( + wopi_f["vp"], uname, True, True, False, self.args.wopi_wdel + ) + self.uname = uname + except Exception as ex: + self.conn.hsrv.wopi_files.pop(wopi_a, None) + self.cbonk(self.conn.hsrv.gpwd, wopi_a, "wopi", "bad wopi tokens") + self.loud_reply("bad wopi token %s (%s)" % (wopi_a, ex), status=400) + return False self.rvol = self.asrv.vfs.aread[self.uname] self.wvol = self.asrv.vfs.awrite[self.uname] @@ -1532,8 +1553,137 @@ 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: + atoken = self.uparam["access_token"] + session = self.conn.hsrv.wopi_files[atoken] + if self.do_log: + self.log(" `-- wopi: %r" % (session["vp"],)) + + zs = "wopi/files/%s" % (session["file_id"],) + if not self.vpath.startswith(zs): + return self.tx_404() + query = self.vpath[len(zs) :] + + vfs, rem = self.asrv.vfs.get(session["vp"], self.uname, True, True) + vpath = vjoin(vfs.vpath, rem) + ap = vfs.canonical(rem) + if query.startswith("/contents"): + return self.tx_file("oh_f", ap) + else: + st = bos.stat(ap) + file_info = { + "BaseFileName": vpath.split("/")[-1], + "Size": st.st_size, + "OwnerId": self.uname, + "UserId": self.uname, + "UserFriendlyName": self.uname, + "UserCanWrite": True, + "UserCanNotWriteRelative": True, + "LastModifiedTime": time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime) + ), + } + 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: + vpath = vjoin(self.vpath, self.uparam["wopi"]) + vfs, rem = self.asrv.vfs.get( + vpath, self.uname, True, True, False, self.args.wopi_wdel + ) + if not bos.path.isfile(vfs.canonical(rem)): + return self.tx_404() + + wopi_files = self.conn.hsrv.wopi_files + found = None + rm = [] + with self.conn.hsrv.mutex: + now = time.time() + for atoken, session in wopi_files.items(): + if session["expires"] < now: + rm.append(atoken) + continue + if session["vp"] != vpath or session["uname"] != self.uname: + continue + if session["expires"] - now < self.args.wopi_ttl * 0.9: + rm.append(atoken) + continue + found = session + break + for zs in rm: + del wopi_files[zs] + if len(wopi_files) > 9000: # about 6 MiB + raise Pebkac(500, "too many wopi sessions") + if not found: + atoken = ub64enc(os.urandom(18)).decode("ascii") # 18 = 144b = 24c + file_id = ub64enc(os.urandom(15)).decode("ascii") # 15 = 120b = 20c + wopi_files[atoken] = session = { + "vp": vpath, + "uname": self.uname, + "file_id": file_id, + "expires": time.time() + self.args.wopi_ttl, + } + + xml = "?" + try: + from .dxml import parse_xml + + uo_kw = {} + if self.args.wopi_crt: + import ssl + + if self.args.wopi_crt == "no": + ctx = ssl._create_unverified_context() + else: + ctx = ssl.create_default_context(cafile=self.args.wopi_crt) + ctx.check_hostname = not self.args.wopi_crt_icn + + uo_kw["context"] = ctx + + url = self.args.wopi_url.rstrip("/") + "/hosting/discovery" + buf = urlopen(url, **uo_kw).read() + xml = buf.decode("ascii", "replace").lower() + enc = self.get_xml_enc(xml) + xml = buf.decode(enc, "replace") + xroot = parse_xml(xml) + ext = vpath.split(".")[-1] + url = xroot.find(".//action[@ext='%s'][@urlsrc]" % (ext,)).get("urlsrc") + if not url.endswith(("?", "&")): + url += "&" if "?" in url else "?" + url += "WOPISrc=" + if self.args.wopi_api: + zs = self.args.wopi_api.rstrip("/") + else: + zs = ("https://" if self.is_https else "http://") + self.host + url += quotep(zs + "/wopi/files/" + session["file_id"]) + except: + del wopi_files[atoken] # dont reuse an atoken wopi-client doesnt like + self.log("reading WOPI-client response failed; %s\n%s" % (min_ex(), xml), 3) + raise Pebkac(500, "wopi error (see fileserver log)") + + html = self.j2s( + "wopi", + title=self.uparam["wopi"], + url=url, + atoken=atoken, + ttl=session["expires"], + ).encode("utf-8", "replace") + + self.reply(html, 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)) @@ -2387,6 +2537,9 @@ class HttpCli(object): raise Pebkac(405, "POST(%r) is disabled in server config" % (ctype,)) + if self.args.wopi and self.vpath.startswith("wopi"): + return self.handle_post_binary() + raise Pebkac(405, "don't know how to handle POST(%r)" % (ctype,)) def handle_smsg(self) -> bool: @@ -3156,6 +3309,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(postsize) + try: chashes = self.headers["x-up2k-hash"].split(",") wark = self.headers["x-up2k-wark"] @@ -3367,6 +3523,47 @@ class HttpCli(object): self.reply(b"thank") return True + def rx_wopi(self, postsize: int) -> bool: + atoken = self.uparam["access_token"] + session = self.conn.hsrv.wopi_files[atoken] + if self.do_log: + self.log(" `-- wopi: %r" % (session["vp"],)) + + zs = "wopi/files/%s/contents" % (session["file_id"],) + if not self.vpath.startswith(zs): + return self.tx_404() + + vpath = self.conn.hsrv.wopi_files[self.uparam["access_token"]]["vp"] + vfs, rem = self.asrv.vfs.get(vpath, self.uname, False, True) + vpath = vjoin(vfs.vpath, rem) + ap = vfs.canonical(rem) + st = bos.stat(ap) + + if "x-cool-wopi-timestamp" in self.headers: + zs = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime)) + if self.headers["x-cool-wopi-timestamp"] != zs: + self.reply(json.dumps({"COOLStatusCode": 1010}).encode("utf-8"), 409) + return True + + buf = b"" + for rbuf in self.get_body_reader()[0]: + buf += rbuf + if not rbuf: + break + + if len(buf) != postsize: + t = "wopi post with incorrect length; expected %d, got %d" + raise Pebkac(400, t % (postsize, len(buf))) + + with open(ap, "wb") as file: + file.write(buf) + + st = bos.stat(ap) + zs = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime)) + ret = json.dumps({"LastModifiedTime": zs}).encode("utf-8", "replace") + self.reply(ret, 200, "application/json; charset=utf-8") + return 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..bcea1d77 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -180,6 +180,8 @@ class HttpSrv(object): self.u2idx_free: dict[str, U2idx] = {} self.u2idx_n = 0 + self.wopi_files: dict[str, dict[str, str]] = {} + assert jinja2 # type: ignore # !rm env = jinja2.Environment() env.loader = jinja2.FunctionLoader(lambda f: load_jinja2_resource(self.E, f)) @@ -195,6 +197,7 @@ class HttpSrv(object): "shares", "splash", "svcs", + "wopi", ] self.j2 = {x: env.get_template(x + ".html") for x in jn} self.j2["opds"] = env.get_template("opds.xml") diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 96954f2e..5172ca67 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -1107,14 +1107,19 @@ class SvcHub(object): t = "not listening on any ip-addresses (only unix-sockets and/or FDs); cannot enable zeroconf/mdns/ssdp as requested" self.log("root", t, 3) - if not self.args.no_dav: + if self.args.wopi or not self.args.no_dav: from .dxml import DXML_OK if not DXML_OK: - if not self.args.no_dav: - self.args.no_dav = True - t = "WARNING:\nDisabling WebDAV support because dxml selftest failed. Please report this bug;\n%s\n...and include the following information in the bug-report:\n%s | expat %s\n" - self.log("root", t % (URL_BUG, VERSIONS, expat_ver()), 1) + self.args.wopi = False + self.args.no_dav = True + t = "WARNING:\nDisabling WebDAV and WOPI support because dxml selftest failed. Please report this bug;\n%s\n...and include the following information in the bug-report:\n%s | expat %s\n" + self.log("root", t % (URL_BUG, VERSIONS, expat_ver()), 1) + + if self.args.wopi and self.args.j != 1: + self.args.wopi = False + t = "WARNING: Disabling --wopi because -j is not 1 (the default and recommended -j value)" + self.log("root", t, 1) if ( not E.scfg diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 60d3c8a4..0e505545 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -1248,6 +1248,9 @@ var img_re = APPLE ? /\.(a?png|avif|bmp|gif|hei[cf]s?|jpe?g|jfif|svg|webp|webm|mkv|mp4|m4v|mov)(\?|$)/i : /\.(a?png|avif|bmp|gif|jpe?g|jfif|svg|webp|webm|mkv|mp4|m4v|mov)(\?|$)/i; +var wopi_set = !window.have_wopi ? null : + new Set('odt fodt ott doc docx dotx rtf odm ods fods ots xls xlsx odp fodp otp ppt pptx ppsx odg fodg otg odf'.split(' ')); + function set_files_html(html) { var files = ebi('files'); @@ -7686,6 +7689,11 @@ var treectl = (function () { tn.href = addq(tn.href, 'v'); } + + if (wopi_set && wopi_set.has(tn.ext)) + tn.lead = '📄'; + if (tn.lead == '-') tn.lead = '