From 8ef6dda74bcb09a812d9c8f7e72c0b46a48b74d9 Mon Sep 17 00:00:00 2001 From: AppleTheGolden Date: Sun, 12 Oct 2025 01:17:24 +0200 Subject: [PATCH] view .cbz in browser (#916) adds functionality to allow browsing .cbz directly in the browser, without downloading them and using a separate program. meant for quickly inspecting the contents, less so for reading. adds two new api calls, ?zls and ?zget, which return a file listing of a zip file and a specific file in the archive, respectively. uses the zipfile module, so no support for .cbr etc --- copyparty/__main__.py | 1 + copyparty/authsrv.py | 1 + copyparty/httpcli.py | 63 ++++++++++++++++++++++++ copyparty/mtag.py | 4 +- copyparty/web/baguettebox.js | 94 ++++++++++++++++++++++++++++++++++-- copyparty/web/browser.js | 19 +++----- docs/devnotes.md | 2 + 7 files changed, 166 insertions(+), 18 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 0da5fa1e..3f0f6624 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1528,6 +1528,7 @@ def add_optouts(ap): ap2.add_argument("--no-pipe", action="store_true", help="disable race-the-beam (lockstep download of files which are currently being uploaded) (volflag=nopipe)") ap2.add_argument("--no-tail", action="store_true", help="disable streaming a growing files with ?tail (volflag=notail)") ap2.add_argument("--no-db-ip", action="store_true", help="do not write uploader-IP into the database; will also disable unpost, you may want \033[32m--forget-ip\033[0m instead (volflag=no_db_ip)") + ap2.add_argument("--no-zls", action="store_true", help="disable browsing the contents of zip/cbz files, does not affect thumbnails") def add_safety(ap): diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 243fab2e..3e6b92e2 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -3027,6 +3027,7 @@ class AuthSrv(object): "have_shr": self.args.shr, "shr_who": vf["shr_who"], "have_zip": not self.args.no_zip, + "have_zls": not self.args.no_zls, "have_mv": not self.args.no_mv, "have_del": not self.args.no_del, "have_unpost": int(self.args.unpost), diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index f5ede83c..ed5ac993 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1521,6 +1521,63 @@ class HttpCli(object): self.log("rss: %d hits, %d bytes" % (len(hits), len(bret))) return True + def tx_zls(self, abspath) -> bool: + if self.do_log: + self.log("zls %s @%s" % (self.req, self.uname)) + if self.args.no_zls: + raise Pebkac(405, "zip browsing is disabled in server config") + + import zipfile + + try: + with zipfile.ZipFile(abspath, "r") as zf: + filelist = [{"fn": f.filename} for f in zf.infolist()] + ret = json.dumps(filelist).encode("utf-8", "replace") + self.reply(ret, mime="application/json") + return True + except (zipfile.BadZipfile, RuntimeError): + raise Pebkac(404, "requested file is not a valid zip file") + + def tx_zget(self, abspath) -> bool: + maxsz = 1024 * 1024 * 64 + + inner_path = self.uparam.get("zget") + if not inner_path: + raise Pebkac(405, "inner path is required") + if self.do_log: + self.log( + "zget %s \033[35m%s\033[0m @%s" % (self.req, inner_path, self.uname) + ) + if self.args.no_zls: + raise Pebkac(405, "zip browsing is disabled in server config") + + import zipfile + + try: + with zipfile.ZipFile(abspath, "r") as zf: + zi = zf.getinfo(inner_path) + if zi.file_size >= maxsz: + raise Pebkac(404, "zip bomb defused") + with zf.open(zi, "r") as fi: + self.send_headers(length=zi.file_size, mime=guess_mime(inner_path)) + + sendfile_py( + self.log, + 0, + zi.file_size, + fi, + self.s, + self.args.s_wr_sz, + self.args.s_wr_slp, + not self.args.no_poll, + {}, + "", + ) + except KeyError: + raise Pebkac(404, "no such file in archive") + except (zipfile.BadZipfile, RuntimeError): + raise Pebkac(404, "requested file is not a valid zip file") + def handle_propfind(self) -> bool: if self.do_log: self.log("PFIND %s @%s" % (self.req, self.uname)) @@ -6480,6 +6537,11 @@ class HttpCli(object): ): return self.tx_md(vn, abspath) + if "zls" in self.uparam: + return self.tx_zls(abspath) + if "zget" in self.uparam: + return self.tx_zget(abspath) + if not add_og or not og_fn: return self.tx_file( abspath, None if st.st_size or "nopipe" in vn.flags else vn.realpath @@ -6569,6 +6631,7 @@ class HttpCli(object): "acct": self.uname, "perms": perms, } + # also see `js_htm` in authsrv.py j2a = { "cgv1": vn.js_htm, "cgv": cgv, diff --git a/copyparty/mtag.py b/copyparty/mtag.py index b9058a5a..a22fa0dd 100644 --- a/copyparty/mtag.py +++ b/copyparty/mtag.py @@ -168,12 +168,12 @@ def au_unpk( znil = [x for x in znil if "cover" in x[0]] or znil znil = [x for x in znil if CBZ_01.search(x[0])] or znil t = "cbz: %d files, %d hits" % (nf, len(znil)) + if not znil: + raise Exception("no images inside cbz") using = sorted(znil)[0][1].filename if znil: t += ", using " + using log(t) - if not znil: - raise Exception("no images inside cbz") fi = zf.open(using) elif pk == "epub": diff --git a/copyparty/web/baguettebox.js b/copyparty/web/baguettebox.js index 08857282..cd140c9f 100644 --- a/copyparty/web/baguettebox.js +++ b/copyparty/web/baguettebox.js @@ -34,6 +34,8 @@ window.baguetteBox = (function () { scrollTimer = 0, re_i = /^[^?]+\.(a?png|avif|bmp|gif|heif|jpe?g|jfif|svg|webp)(\?|$)/i, re_v = /^[^?]+\.(webm|mkv|mp4|m4v|mov)(\?|$)/i, + re_cbz = /^[^?]+\.(cbz)(\?|$)/i, + cbz_pics = ["png", "jpg", "jpeg", "gif", "bmp", "tga", "tif", "tiff", "webp", "avif"], anims = ['slideIn', 'fadeIn', 'none'], data = {}, // all galleries imagesElements = [], @@ -147,6 +149,8 @@ window.baguetteBox = (function () { tagsNodeList = [galleryElement]; else tagsNodeList = galleryElement.getElementsByTagName('a'); + if (have_zls) + bindCbzClickListeners(tagsNodeList, userOptions); tagsNodeList = [].filter.call(tagsNodeList, function (element) { if (element.className.indexOf(userOptions && userOptions.ignoreClass) === -1) @@ -167,7 +171,7 @@ window.baguetteBox = (function () { }; var imageItem = { eventHandler: imageElementClickHandler, - imageElement: imageElement + imageElement: imageElement, }; bind(imageElement, 'click', imageElementClickHandler); gallery.push(imageItem); @@ -178,6 +182,86 @@ window.baguetteBox = (function () { return [selectorData.galleries, options]; } + function bindCbzClickListeners(tagsNodeList, userOptions) { + var cbzNodes = [].filter.call(tagsNodeList, function (element) { + return re_cbz.test(element.href); + }); + if (!tagsNodeList.length) { + return; + } + + [].forEach.call(cbzNodes, function (cbzElement, index) { + var gallery = []; + var eventHandler = function (e) { + if (ctrl(e) || e && e.shiftKey) + return true; + + e.preventDefault ? e.preventDefault() : e.returnValue = false; + fillCbzGallery(gallery, cbzElement, eventHandler).then(function () { + prepareOverlay(gallery, userOptions); + showOverlay(0); + } + ).catch(function (reason) { + console.error("cbz-ded", reason); + var t; + try { + t = uricom_dec(cbzElement.href.split('/').pop()); + } catch (ex) { } + + var msg = "Could not browse " + (t ? t : 'archive'); + try { + msg += "\n\n" + reason.message; + } catch (ex) { } + toast.err(20, msg, 'cbz-ded'); + }); + } + + bind(cbzElement, "click", eventHandler); + }) + } + + function fillCbzGallery(gallery, cbzElement, eventHandler) { + if (gallery.length !== 0) { + return Promise.resolve(); + } + var href = cbzElement.href; + var zlsHref = href + (href.indexOf("?") === -1 ? "?" : "&") + "zls"; + return fetch(zlsHref) + .then(function (response) { + if (response.ok) { + return response.json(); + } else { + throw new Error("Archive is invalid"); + } + }) + .then(function (fileList) { + var imagesList = fileList.map(function (file) { + return file["fn"]; + }).filter(function (file) { + return file.indexOf(".") !== -1 + && cbz_pics.indexOf(file.split(".").pop()) !== -1; + }).sort(); + + if (imagesList.length === 0) { + throw new Error("Archive does not contain any images"); + } + + imagesList.forEach(function (imageName, index) { + var imageHref = href + + (href.indexOf("?") === -1 ? "?" : "&") + + "zget=" + + encodeURIComponent(imageName); + + var galleryItem = { + href: imageHref, + imageElement: cbzElement, + eventHandler: eventHandler, + }; + gallery.push(galleryItem); + }); + }); + } + function clearCachedData() { for (var selector in data) if (data.hasOwnProperty(selector)) @@ -658,7 +742,7 @@ window.baguetteBox = (function () { }, 50); if (options.onChange && !url_ts) - options.onChange(currentIndex, imagesElements.length); + options.onChange.call(currentGallery, currentIndex, imagesElements.length); url_ts = null; documentLastFocus = document.activeElement; @@ -786,11 +870,11 @@ window.baguetteBox = (function () { imageContainer.removeChild(imageContainer.firstChild); var imageElement = galleryItem.imageElement, - imageSrc = imageElement.href, + imageSrc = galleryItem.href || imageElement.href, is_vid = re_v.test(imageSrc), thumbnailElement = imageElement.querySelector('img, video'), imageCaption = typeof options.captions === 'function' ? - options.captions.call(currentGallery, imageElement) : + options.captions.call(currentGallery, imageElement, index) : imageElement.getAttribute('data-caption') || imageElement.title; imageSrc = addq(imageSrc, 'cache'); @@ -930,7 +1014,7 @@ window.baguetteBox = (function () { unfig(index); if (options.onChange) - options.onChange(currentIndex, imagesElements.length); + options.onChange.call(currentGallery, currentIndex, imagesElements.length); return true; } diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 880d295e..da7a73c5 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -17576,23 +17576,20 @@ var thegrid = (function () { afterShow: function () { r.bbox_opts.refocus = true; }, - captions: function (g) { - var idx = -1, - h = '' + g; - - for (var a = 0; a < r.bbox.length; a++) - if (r.bbox[a].imageElement == g) - idx = a; + captions: function (g, idx) { + var h = '' + g; return '' + (idx + 1) + ' / ' + r.bbox.length + ' -- ' + + '">' + (idx + 1) + ' / ' + this.length + ' -- ' + esc(uricom_dec(h.split('/').pop())) + ''; }, - onChange: function (i) { - sethash('g' + r.bbox[i].imageElement.getAttribute('ref') + getsort()); + onChange: function (i, maxIdx) { + if (this[i].imageElement) { + sethash('g' + this[i].imageElement.getAttribute('ref') + getsort()); + } } }); - r.bbox = br[0][0]; + r.bbox = true; r.bbox_opts = br[1]; }; diff --git a/docs/devnotes.md b/docs/devnotes.md index 0a2bc999..50bafc89 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -200,6 +200,8 @@ authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo` | GET | `?th` | get image/video at URL as thumbnail | | GET | `?th=opus` | convert audio file to 128kbps opus | | GET | `?th=caf` | ...in the iOS-proprietary container | +| GET | `?zls` | get listing of filepaths in zip file at URL | +| GET | `?zget=path` | get specific file from inside a zip file at URL | | method | body | result | |--|--|--|