mirror of
https://github.com/9001/copyparty.git
synced 2026-08-13 13:53: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)")
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' : '') +
|
||||
|
|
|
|||
Loading…
Reference in a new issue