This commit is contained in:
Brandon Doornbos 2026-06-16 19:53:04 -04:00 committed by GitHub
commit d36216886f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 194 additions and 1 deletions

View file

@ -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)") 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): def add_handlers(ap):
ap2 = ap.add_argument_group("handlers (see --help-handlers)") 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") 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_tftp(ap)
add_smb(ap) add_smb(ap)
add_opds(ap) add_opds(ap)
add_wopi(ap)
add_safety(ap) add_safety(ap)
add_salt(ap, fk_salt, dk_salt, ah_salt) add_salt(ap, fk_salt, dk_salt, ah_salt)
add_optouts(ap) add_optouts(ap)

View file

@ -394,6 +394,10 @@ flagcats = {
"og_no_head": "you want to add tags manually with og_tpl", "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", "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": {
"opds": "enable OPDS", "opds": "enable OPDS",
"opds_exts": "file formats to list in OPDS feeds; leave empty to show everything", "opds_exts": "file formats to list in OPDS feeds; leave empty to show everything",

View file

@ -16,6 +16,9 @@ import sys
import threading # typechk import threading # typechk
import time import time
import uuid import uuid
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
from datetime import datetime from datetime import datetime
from operator import itemgetter from operator import itemgetter
@ -798,6 +801,12 @@ class HttpCli(object):
else: else:
self.log("unknown username: %r" % (idp_usr,), 1) 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.have_ipu_or_ipr:
if self.args.ipu and (self.uname == "*" or self.args.ao_ipu_wins): 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)] 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: if "rss" in self.uparam:
return self.tx_rss() 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() 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 = [
"""\
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="%s" />
<title>%s</title>
<style>
body {
margin: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#viewer {
width: 100%%;
height: 100%%;
border: 0;
}
</style>
</head>
<body>
<div style="display: none">
<form action="%s" enctype="multipart/form-data" method="post" target="viewer" id="submit-form">
<input name="access_token" value="%s" type="hidden" id="access-token"/>
<input type="submit" value="" />
</form>
</div>
<iframe id="viewer" name="viewer" allow="clipboard-read *; clipboard-write *; fullscreen *"></iframe>
<script>
document.getElementById("submit-form").submit();
</script>
</body>
</html>
"""
% (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: def tx_rss(self) -> bool:
if self.do_log: if self.do_log:
self.log("RSS %s @%s" % (self.req, self.uname)) self.log("RSS %s @%s" % (self.req, self.uname))
@ -3149,6 +3270,9 @@ class HttpCli(object):
except: except:
raise Pebkac(400, "you must supply a content-length for binary POST") 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: try:
chashes = self.headers["x-up2k-hash"].split(",") chashes = self.headers["x-up2k-hash"].split(",")
wark = self.headers["x-up2k-wark"] wark = self.headers["x-up2k-wark"]
@ -3360,6 +3484,47 @@ class HttpCli(object):
self.reply(b"thank") self.reply(b"thank")
return True 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: def handle_chpw(self) -> bool:
assert self.parser # !rm assert self.parser # !rm
if self.args.usernames: if self.args.usernames:

View file

@ -242,6 +242,9 @@ class HttpSrv(object):
if (HAVE_PIL or HAVE_VIPS or HAVE_FFMPEG) and not self.args.no_thumb: if (HAVE_PIL or HAVE_VIPS or HAVE_FFMPEG) and not self.args.no_thumb:
Daemon(self.post_init, "hsrv-init2") Daemon(self.post_init, "hsrv-init2")
if self.args.wopi:
self.wopi_files: dict[str, dict[str, str]] = {}
def post_init(self) -> None: def post_init(self) -> None:
try: try:
x = self.broker.ask("thumbsrv.getcfg") x = self.broker.ask("thumbsrv.getcfg")

View file

@ -7665,10 +7665,24 @@ var treectl = (function () {
tn.href = addq(tn.href, 'v'); 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 = '<a href="?wopi=' + bhref + '" id="t' + id +
'" rel="nofollow" target="blank" name="' + hname + '">📄</a>';
} else if (tn.lead == '-') {
tn.lead = '<a href="?doc=' + bhref + '" id="t' + id + tn.lead = '<a href="?doc=' + bhref + '" id="t' + id +
'" rel="nofollow" class="doc' + (lang ? ' bri' : '') + '" rel="nofollow" class="doc' + (lang ? ' bri' : '') +
'" hl="' + id + '" name="' + hname + '">-txt-</a>'; '" hl="' + id + '" name="' + hname + '">-txt-</a>';
}
var cl = /\.PARTIAL$/.exec(fname) ? ' class="fade"' : '', var cl = /\.PARTIAL$/.exec(fname) ? ' class="fade"' : '',
ln = ['<tr' + cl + '><td>' + tn.lead + '</td><td><a href="' + ln = ['<tr' + cl + '><td>' + tn.lead + '</td><td><a href="' +