basic wopi integration

---------

Signed-off-by: ed <s@ocv.me>
Co-authored-by: ed <s@ocv.me>
This commit is contained in:
Brandon Doornbos 2026-07-24 17:28:05 +02:00 committed by GitHub
parent fface524c3
commit d57bb0c701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 231 additions and 6 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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")

View file

@ -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

View file

@ -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 = '<a href="?wopi=' + bhref +
'" rel="nofollow" name="' + hname + '">📄</a>';
if (tn.lead == '-')
tn.lead = '<a href="?doc=' + bhref + '" id="t' + id +
'" rel="nofollow" class="doc' + (lang ? ' bri' : '') +