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 = '