mirror of
https://github.com/9001/copyparty.git
synced 2026-08-13 22:03:06 -06:00
add wopi (#1495)
basic wopi integration --------- Signed-off-by: ed <s@ocv.me> Co-authored-by: ed <s@ocv.me>
This commit is contained in:
parent
fface524c3
commit
d57bb0c701
|
|
@ -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)")
|
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):
|
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")
|
||||||
|
|
@ -2093,6 +2104,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)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ try:
|
||||||
except:
|
except:
|
||||||
pass
|
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 .__version__ import S_VERSION
|
||||||
from .authsrv import LEELOO_DALLAS, VFS # typechk
|
from .authsrv import LEELOO_DALLAS, VFS # typechk
|
||||||
from .bos import bos
|
from .bos import bos
|
||||||
|
|
@ -149,6 +149,11 @@ if True: # pylint: disable=using-constant-test
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .httpconn import HttpConn
|
from .httpconn import HttpConn
|
||||||
|
|
||||||
|
if PY2:
|
||||||
|
from urllib2 import urlopen
|
||||||
|
else:
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
if not hasattr(socket, "AF_UNIX"):
|
if not hasattr(socket, "AF_UNIX"):
|
||||||
setattr(socket, "AF_UNIX", -9001)
|
setattr(socket, "AF_UNIX", -9001)
|
||||||
|
|
||||||
|
|
@ -813,6 +818,22 @@ class HttpCli(object):
|
||||||
if not ipr[self.uname].map(self.ip):
|
if not ipr[self.uname].map(self.ip):
|
||||||
self.log("username [%s] rejected by --ipr" % (self.uname,), 3)
|
self.log("username [%s] rejected by --ipr" % (self.uname,), 3)
|
||||||
self.uname = "*"
|
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.rvol = self.asrv.vfs.aread[self.uname]
|
||||||
self.wvol = self.asrv.vfs.awrite[self.uname]
|
self.wvol = self.asrv.vfs.awrite[self.uname]
|
||||||
|
|
@ -1532,8 +1553,137 @@ 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:
|
||||||
|
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:
|
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))
|
||||||
|
|
@ -2387,6 +2537,9 @@ class HttpCli(object):
|
||||||
|
|
||||||
raise Pebkac(405, "POST(%r) is disabled in server config" % (ctype,))
|
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,))
|
raise Pebkac(405, "don't know how to handle POST(%r)" % (ctype,))
|
||||||
|
|
||||||
def handle_smsg(self) -> bool:
|
def handle_smsg(self) -> bool:
|
||||||
|
|
@ -3156,6 +3309,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(postsize)
|
||||||
|
|
||||||
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"]
|
||||||
|
|
@ -3367,6 +3523,47 @@ class HttpCli(object):
|
||||||
self.reply(b"thank")
|
self.reply(b"thank")
|
||||||
return True
|
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:
|
def handle_chpw(self) -> bool:
|
||||||
assert self.parser # !rm
|
assert self.parser # !rm
|
||||||
if self.args.usernames:
|
if self.args.usernames:
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,8 @@ class HttpSrv(object):
|
||||||
self.u2idx_free: dict[str, U2idx] = {}
|
self.u2idx_free: dict[str, U2idx] = {}
|
||||||
self.u2idx_n = 0
|
self.u2idx_n = 0
|
||||||
|
|
||||||
|
self.wopi_files: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
assert jinja2 # type: ignore # !rm
|
assert jinja2 # type: ignore # !rm
|
||||||
env = jinja2.Environment()
|
env = jinja2.Environment()
|
||||||
env.loader = jinja2.FunctionLoader(lambda f: load_jinja2_resource(self.E, f))
|
env.loader = jinja2.FunctionLoader(lambda f: load_jinja2_resource(self.E, f))
|
||||||
|
|
@ -195,6 +197,7 @@ class HttpSrv(object):
|
||||||
"shares",
|
"shares",
|
||||||
"splash",
|
"splash",
|
||||||
"svcs",
|
"svcs",
|
||||||
|
"wopi",
|
||||||
]
|
]
|
||||||
self.j2 = {x: env.get_template(x + ".html") for x in jn}
|
self.j2 = {x: env.get_template(x + ".html") for x in jn}
|
||||||
self.j2["opds"] = env.get_template("opds.xml")
|
self.j2["opds"] = env.get_template("opds.xml")
|
||||||
|
|
|
||||||
|
|
@ -1107,15 +1107,20 @@ class SvcHub(object):
|
||||||
t = "not listening on any ip-addresses (only unix-sockets and/or FDs); cannot enable zeroconf/mdns/ssdp as requested"
|
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)
|
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
|
from .dxml import DXML_OK
|
||||||
|
|
||||||
if not DXML_OK:
|
if not DXML_OK:
|
||||||
if not self.args.no_dav:
|
self.args.wopi = False
|
||||||
self.args.no_dav = True
|
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"
|
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)
|
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 (
|
if (
|
||||||
not E.scfg
|
not E.scfg
|
||||||
and not al.unsafe_state
|
and not al.unsafe_state
|
||||||
|
|
|
||||||
|
|
@ -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|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;
|
/\.(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) {
|
function set_files_html(html) {
|
||||||
var files = ebi('files');
|
var files = ebi('files');
|
||||||
|
|
@ -7686,6 +7689,11 @@ var treectl = (function () {
|
||||||
tn.href = addq(tn.href, 'v');
|
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 == '-')
|
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' : '') +
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue