From e5582605cd61e9fe0b81523a316c6a2ada3b338a Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 24 Feb 2024 21:22:55 +0000 Subject: [PATCH 01/22] fix md-editor preview on small screens; the left side of the preview pane would go off-screen --- copyparty/web/md2.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copyparty/web/md2.css b/copyparty/web/md2.css index f06d0bb8..af6139ab 100644 --- a/copyparty/web/md2.css +++ b/copyparty/web/md2.css @@ -9,7 +9,7 @@ width: calc(100% - 56em); } #mw { - left: calc(100% - 55em); + left: max(0em, calc(100% - 55em)); overflow-y: auto; position: fixed; bottom: 0; From ac96fd9c96b850c95f14a32bce1eb78b5e3f9c1b Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 24 Feb 2024 22:24:44 +0000 Subject: [PATCH 02/22] get rid of brotli due to poor support; closes #73 some reverse-proxies expect plaintext replies, and we don't have a brotli decompressor to satisfy this additionally, because brotli is https-gated (thx google), it was already an impractical mess anyways the sfx is now 7 KiB larger --- copyparty/httpcli.py | 20 +++++--------------- copyparty/httpsrv.py | 2 +- copyparty/web/md.js | 7 ------- docs/devnotes.md | 2 +- scripts/deps-docker/Dockerfile | 5 ++--- scripts/deps-docker/brotli.makefile | 4 ---- scripts/make-sfx.sh | 6 +++--- 7 files changed, 12 insertions(+), 34 deletions(-) delete mode 100644 scripts/deps-docker/brotli.makefile diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 12b0f68e..88b7c215 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -2827,11 +2827,11 @@ class HttpCli(object): logtail = "" # - # if request is for foo.js, check if we have foo.js.{gz,br} + # if request is for foo.js, check if we have foo.js.gz file_ts = 0.0 editions: dict[str, tuple[str, int]] = {} - for ext in ["", ".gz", ".br"]: + for ext in ("", ".gz"): try: fs_path = req_path + ext st = bos.stat(fs_path) @@ -2876,12 +2876,7 @@ class HttpCli(object): x.strip() for x in self.headers.get("accept-encoding", "").lower().split(",") ] - if ".br" in editions and "br" in supported_editions: - is_compressed = True - selected_edition = ".br" - fs_path, file_sz = editions[".br"] - self.out_headers["Content-Encoding"] = "br" - elif ".gz" in editions: + if ".gz" in editions: is_compressed = True selected_edition = ".gz" fs_path, file_sz = editions[".gz"] @@ -2897,13 +2892,8 @@ class HttpCli(object): is_compressed = False selected_edition = "plain" - try: - fs_path, file_sz = editions[selected_edition] - logmsg += "{} ".format(selected_edition.lstrip(".")) - except: - # client is old and we only have .br - # (could make brotli a dep to fix this but it's not worth) - raise Pebkac(404) + fs_path, file_sz = editions[selected_edition] + logmsg += "{} ".format(selected_edition.lstrip(".")) # # partial diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 40bd108f..cade2df7 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -191,7 +191,7 @@ class HttpSrv(object): for fn in df: ap = absreal(os.path.join(dp, fn)) self.statics.add(ap) - if ap.endswith(".gz") or ap.endswith(".br"): + if ap.endswith(".gz"): self.statics.add(ap[:-3]) def set_netdevs(self, netdevs: dict[str, Netdev]) -> None: diff --git a/copyparty/web/md.js b/copyparty/web/md.js index 4dd29a37..d255b2c4 100644 --- a/copyparty/web/md.js +++ b/copyparty/web/md.js @@ -512,13 +512,6 @@ dom_navtgl.onclick = function () { redraw(); }; -if (!HTTPS && location.hostname != '127.0.0.1') try { - ebi('edit2').onclick = function (e) { - toast.err(0, "the fancy editor is only available over https"); - return ev(e); - } -} catch (ex) { } - if (sread('hidenav') == 1) dom_navtgl.onclick(); diff --git a/docs/devnotes.md b/docs/devnotes.md index 49023b81..72f331c0 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -218,7 +218,7 @@ if you don't need all the features, you can repack the sfx and save a bunch of s * `269k` after `./scripts/make-sfx.sh re no-cm no-hl` the features you can opt to drop are -* `cm`/easymde, the "fancy" markdown editor, saves ~82k +* `cm`/easymde, the "fancy" markdown editor, saves ~89k * `hl`, prism, the syntax hilighter, saves ~41k * `fnt`, source-code-pro, the monospace font, saves ~9k * `dd`, the custom mouse cursor for the media player tray tab, saves ~2k diff --git a/scripts/deps-docker/Dockerfile b/scripts/deps-docker/Dockerfile index 96c08e88..20e86a9e 100644 --- a/scripts/deps-docker/Dockerfile +++ b/scripts/deps-docker/Dockerfile @@ -24,7 +24,7 @@ ENV ver_asmcrypto=c72492f4a66e17a0e5dd8ad7874de354f3ccdaa5 \ # the scp url is regular latin from https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap RUN mkdir -p /z/dist/no-pk \ && wget https://fonts.gstatic.com/s/sourcecodepro/v11/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevW.woff2 -O scp.woff2 \ - && apk add cmake make g++ git bash npm patch wget tar pigz brotli gzip unzip python3 python3-dev brotli py3-brotli \ + && apk add cmake make g++ git bash npm patch wget tar pigz brotli gzip unzip python3 python3-dev py3-brotli \ && rm -f /usr/lib/python3*/EXTERNALLY-MANAGED \ && wget https://github.com/openpgpjs/asmcrypto.js/archive/$ver_asmcrypto.tar.gz -O asmcrypto.tgz \ && wget https://github.com/markedjs/marked/archive/v$ver_marked.tar.gz -O marked.tgz \ @@ -143,9 +143,8 @@ RUN ./genprism.sh $ver_prism # compress -COPY brotli.makefile zopfli.makefile /z/dist/ +COPY zopfli.makefile /z/dist/ RUN cd /z/dist \ - && make -j$(nproc) -f brotli.makefile \ && make -j$(nproc) -f zopfli.makefile \ && rm *.makefile \ && mv no-pk/* . \ diff --git a/scripts/deps-docker/brotli.makefile b/scripts/deps-docker/brotli.makefile deleted file mode 100644 index 3860224f..00000000 --- a/scripts/deps-docker/brotli.makefile +++ /dev/null @@ -1,4 +0,0 @@ -all: $(addsuffix .br, $(wildcard easymde*)) - -%.br: % - brotli -jZ $< diff --git a/scripts/make-sfx.sh b/scripts/make-sfx.sh index 0c77f5f2..53c6d2d0 100755 --- a/scripts/make-sfx.sh +++ b/scripts/make-sfx.sh @@ -37,7 +37,7 @@ help() { exec cat <<'EOF' # _____________________________________________________________________ # web features: # -# `no-cm` saves ~82k by removing easymde/codemirror +# `no-cm` saves ~89k by removing easymde/codemirror # (the fancy markdown editor) # # `no-hl` saves ~41k by removing syntax hilighting in the text viewer @@ -406,7 +406,7 @@ find -type f -name ._\* | while IFS= read -r f; do cmp <(printf '\x00\x05\x16') rm -f copyparty/web/deps/*.full.* copyparty/web/dbg-* copyparty/web/Makefile -find copyparty | LC_ALL=C sort | sed -r 's/\.(gz|br)$//;s/$/,/' > have +find copyparty | LC_ALL=C sort | sed -r 's/\.gz$//;s/$/,/' > have cat have | while IFS= read -r x; do grep -qF -- "$x" ../scripts/sfx.ls || { echo "unexpected file: $x" @@ -603,7 +603,7 @@ sed -r 's/(.*)\.(.*)/\2 \1/' | LC_ALL=C sort | sed -r 's/([^ ]*) (.*)/\2.\1/' | grep -vE '/list1?$' > list1 for n in {1..50}; do - (grep -vE '\.(gz|br)$' list1; grep -E '\.(gz|br)$' list1 | (shuf||gshuf) ) >list || true + (grep -vE '\.gz$' list1; grep -E '\.gz$' list1 | (shuf||gshuf) ) >list || true s=$( (sha1sum||shasum) < list | cut -c-16) grep -q $s "$zdir/h" 2>/dev/null && continue echo $s >> "$zdir/h" From 263adec70a62a42cd29d842688c78fbbc173a47a Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 24 Feb 2024 23:30:17 +0000 Subject: [PATCH 03/22] add support for custom fonts; closes #74 --- README.md | 2 ++ copyparty/web/browser.css | 14 ++++++++++++++ copyparty/web/browser.html | 2 +- copyparty/web/browser2.html | 2 +- copyparty/web/md.css | 2 ++ copyparty/web/md.html | 2 +- copyparty/web/md2.css | 1 + copyparty/web/mde.css | 1 + copyparty/web/mde.html | 2 +- copyparty/web/msg.css | 7 +++++++ copyparty/web/msg.html | 2 +- copyparty/web/splash.css | 2 ++ copyparty/web/splash.html | 2 +- copyparty/web/svcs.html | 2 +- copyparty/web/ui.css | 6 ++++++ docs/rice/README.md | 25 +++++++++++++++++++++++++ 16 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 docs/rice/README.md diff --git a/README.md b/README.md index 6f7951a1..675a66f3 100644 --- a/README.md +++ b/README.md @@ -1292,6 +1292,8 @@ the classname of the HTML tag is set according to the selected theme, which is u see the top of [./copyparty/web/browser.css](./copyparty/web/browser.css) where the color variables are set, and there's layout-specific stuff near the bottom +if you want to change the fonts, see [./docs/rice/](./docs/rice/) + ## complete examples diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css index ca31c7a3..f7a98132 100644 --- a/copyparty/web/browser.css +++ b/copyparty/web/browser.css @@ -494,6 +494,7 @@ html.dz { text-shadow: none; font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } html.dy { --fg: #000; @@ -603,6 +604,7 @@ html { color: var(--fg); background: var(--bgg); font-family: sans-serif; + font-family: var(--font-main), sans-serif; text-shadow: 1px 1px 0px var(--bg-max); } html, body { @@ -611,6 +613,7 @@ html, body { } pre, code, tt, #doc, #doc>code { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } .ayjump { position: fixed; @@ -759,6 +762,7 @@ html #files.hhpick thead th { } #files tbody td:nth-child(3) { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; text-align: right; padding-right: 1em; white-space: nowrap; @@ -821,6 +825,7 @@ html.y #path a:hover { .logue.raw { white-space: pre; font-family: 'scp', 'consolas', monospace; + font-family: var(--font-mono), 'scp', 'consolas', monospace; } #doc>iframe, .logue>iframe { @@ -1417,6 +1422,7 @@ input[type="checkbox"]:checked+label { } html.dz input { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } .opwide div>span>input+label { padding: .3em 0 .3em .3em; @@ -1702,6 +1708,7 @@ html.y #tree.nowrap .ntree a+a:hover { } .ntree a:first-child { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; font-size: 1.2em; line-height: 0; } @@ -1859,6 +1866,7 @@ html.y #tree.nowrap .ntree a+a:hover { } #rn_vadv input { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } #rui td+td, #rui td input[type="text"] { @@ -1922,6 +1930,7 @@ html.y #doc { #doc.mdo { white-space: normal; font-family: sans-serif; + font-family: var(--font-main), sans-serif; } #doc.prism * { line-height: 1.5em; @@ -1981,6 +1990,7 @@ a.btn, } #hkhelp td:first-child { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } html.noscroll, html.noscroll .sbar { @@ -2490,6 +2500,7 @@ html.y #bbox-overlay figcaption a { } #op_up2k.srch td.prog { font-family: sans-serif; + font-family: var(--font-main), sans-serif; font-size: 1em; width: auto; } @@ -2504,6 +2515,7 @@ html.y #bbox-overlay figcaption a { white-space: nowrap; display: inline-block; font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } #u2etas.o { width: 20em; @@ -2573,6 +2585,7 @@ html.y #bbox-overlay figcaption a { #u2cards span { color: var(--fg-max); font-family: 'scp', monospace; + font-family: var(--font-mono), 'scp', monospace; } #u2cards > a:nth-child(4) > span { display: inline-block; @@ -2738,6 +2751,7 @@ html.b #u2conf a.b:hover { } .prog { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; } #u2tab span.inf, #u2tab span.ok, diff --git a/copyparty/web/browser.html b/copyparty/web/browser.html index 0f48b211..76f6fcca 100644 --- a/copyparty/web/browser.html +++ b/copyparty/web/browser.html @@ -7,9 +7,9 @@ -{{ html_head }} +{{ html_head }} {%- if css %} {%- endif %} diff --git a/copyparty/web/browser2.html b/copyparty/web/browser2.html index 66d4f0fc..03195b4e 100644 --- a/copyparty/web/browser2.html +++ b/copyparty/web/browser2.html @@ -6,12 +6,12 @@ {{ title }} -{{ html_head }} +{{ html_head }} diff --git a/copyparty/web/md.css b/copyparty/web/md.css index 0b1cd49c..e964865f 100644 --- a/copyparty/web/md.css +++ b/copyparty/web/md.css @@ -2,6 +2,7 @@ html, body { color: #333; background: #eee; font-family: sans-serif; + font-family: var(--font-main), sans-serif; line-height: 1.5em; } html.y #helpbox a { @@ -67,6 +68,7 @@ a { position: relative; display: inline-block; font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; font-weight: bold; font-size: 1.3em; line-height: .1em; diff --git a/copyparty/web/md.html b/copyparty/web/md.html index 07bd5634..1ea1909f 100644 --- a/copyparty/web/md.html +++ b/copyparty/web/md.html @@ -4,12 +4,12 @@ -{{ html_head }} {%- if edit %} {%- endif %} +{{ html_head }}
diff --git a/copyparty/web/md2.css b/copyparty/web/md2.css index af6139ab..ec4a7b29 100644 --- a/copyparty/web/md2.css +++ b/copyparty/web/md2.css @@ -56,6 +56,7 @@ padding: 0; margin: 0; font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; diff --git a/copyparty/web/mde.css b/copyparty/web/mde.css index 7b1d2618..0a8d8ec5 100644 --- a/copyparty/web/mde.css +++ b/copyparty/web/mde.css @@ -17,6 +17,7 @@ html, body { padding: 0; min-height: 100%; font-family: sans-serif; + font-family: var(--font-main), sans-serif; background: #f7f7f7; color: #333; } diff --git a/copyparty/web/mde.html b/copyparty/web/mde.html index d9b3c056..31618851 100644 --- a/copyparty/web/mde.html +++ b/copyparty/web/mde.html @@ -4,11 +4,11 @@ -{{ html_head }} +{{ html_head }}
diff --git a/copyparty/web/msg.css b/copyparty/web/msg.css index 764cee5d..ab8fa4d1 100644 --- a/copyparty/web/msg.css +++ b/copyparty/web/msg.css @@ -1,3 +1,8 @@ +:root { + --font-main: sans-serif; + --font-serif: serif; + --font-mono: 'scp'; +} html,body,tr,th,td,#files,a { color: inherit; background: none; @@ -10,6 +15,7 @@ html { color: #ccc; background: #333; font-family: sans-serif; + font-family: var(--font-main), sans-serif; text-shadow: 1px 1px 0px #000; touch-action: manipulation; } @@ -23,6 +29,7 @@ html, body { } pre { font-family: monospace, monospace; + font-family: var(--font-mono), monospace, monospace; } a { color: #fc5; diff --git a/copyparty/web/msg.html b/copyparty/web/msg.html index 4ef5973a..910bc3ae 100644 --- a/copyparty/web/msg.html +++ b/copyparty/web/msg.html @@ -7,8 +7,8 @@ -{{ html_head }} +{{ html_head }} diff --git a/copyparty/web/splash.css b/copyparty/web/splash.css index c6b8b5b7..5ca37f9c 100644 --- a/copyparty/web/splash.css +++ b/copyparty/web/splash.css @@ -2,6 +2,7 @@ html { color: #333; background: #f7f7f7; font-family: sans-serif; + font-family: var(--font-main), sans-serif; touch-action: manipulation; } #wrap { @@ -127,6 +128,7 @@ pre, code { color: #480; background: #fff; font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; border: 1px solid rgba(128,128,128,0.3); border-radius: .2em; padding: .15em .2em; diff --git a/copyparty/web/splash.html b/copyparty/web/splash.html index 79391610..73bb6d87 100644 --- a/copyparty/web/splash.html +++ b/copyparty/web/splash.html @@ -7,9 +7,9 @@ -{{ html_head }} +{{ html_head }} diff --git a/copyparty/web/svcs.html b/copyparty/web/svcs.html index 49ca9a02..b560b379 100644 --- a/copyparty/web/svcs.html +++ b/copyparty/web/svcs.html @@ -7,10 +7,10 @@ -{{ html_head }} +{{ html_head }} diff --git a/copyparty/web/ui.css b/copyparty/web/ui.css index ffed63e3..eecfc7cf 100644 --- a/copyparty/web/ui.css +++ b/copyparty/web/ui.css @@ -1,4 +1,8 @@ :root { + --font-main: sans-serif; + --font-serif: serif; + --font-mono: 'scp'; + --fg: #ccc; --fg-max: #fff; --bg-u2: #2b2b2b; @@ -378,6 +382,7 @@ html.y textarea:focus { .mdo code, .mdo tt { font-family: 'scp', monospace, monospace; + font-family: var(--font-mono), 'scp', monospace, monospace; white-space: pre-wrap; word-break: break-all; } @@ -447,6 +452,7 @@ html.y textarea:focus { } .mdo blockquote { font-family: serif; + font-family: var(--font-serif), serif; background: #f7f7f7; border: .07em dashed #ccc; padding: 0 2em; diff --git a/docs/rice/README.md b/docs/rice/README.md new file mode 100644 index 00000000..a4943f2a --- /dev/null +++ b/docs/rice/README.md @@ -0,0 +1,25 @@ +# custom fonts + +to change the fonts in the web-UI, first create a css file with your customizations, for example `customfonts.css`, for example in your webroot + +add this to your copyparty config so the css file gets loaded: `--html-head=''` + +make your changes in the css file; this is the default values to get you started: + +```css +:root { + --font-main: sans-serif; + --font-serif: serif; + --font-mono: 'scp'; +} +``` + +if you are introducing a new ttf/woff font, don't forget to declare the font itself in the css file; here's one of the default fonts from `ui.css`: + +```css +@font-face { + font-family: 'scp'; + font-display: swap; + src: local('Source Code Pro Regular'), local('SourceCodePro-Regular'), url(deps/scp.woff2) format('woff2'); +} +``` From 6cc7101d317ef41e82186cceff2a80ff3f244e25 Mon Sep 17 00:00:00 2001 From: ed Date: Sun, 25 Feb 2024 00:15:57 +0000 Subject: [PATCH 04/22] custom-fonts: add config file example (#74) --- docs/rice/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/rice/README.md b/docs/rice/README.md index a4943f2a..e5f318d2 100644 --- a/docs/rice/README.md +++ b/docs/rice/README.md @@ -4,6 +4,13 @@ to change the fonts in the web-UI, first create a css file with your customizat add this to your copyparty config so the css file gets loaded: `--html-head=''` +alternatively, if you are using a config file instead of commandline args: + +```yaml +[global] + html-head: +``` + make your changes in the css file; this is the default values to get you started: ```css From c8ea4066b1ab4ec3c2f2f24151814c0d7c6757dc Mon Sep 17 00:00:00 2001 From: ed Date: Sun, 25 Feb 2024 04:43:32 +0000 Subject: [PATCH 05/22] less confusing explanation hopefully --- docs/rice/README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/rice/README.md b/docs/rice/README.md index e5f318d2..385e3ee3 100644 --- a/docs/rice/README.md +++ b/docs/rice/README.md @@ -1,6 +1,14 @@ # custom fonts -to change the fonts in the web-UI, first create a css file with your customizations, for example `customfonts.css`, for example in your webroot +to change the fonts in the web-UI, first save the following text (the default font-config) to a new css file, for example named `customfonts.css` in your webroot: + +```css +:root { + --font-main: sans-serif; + --font-serif: serif; + --font-mono: 'scp'; +} +``` add this to your copyparty config so the css file gets loaded: `--html-head=''` @@ -11,15 +19,9 @@ alternatively, if you are using a config file instead of commandline args: html-head: ``` -make your changes in the css file; this is the default values to get you started: +restart copyparty for the config change to take effect -```css -:root { - --font-main: sans-serif; - --font-serif: serif; - --font-mono: 'scp'; -} -``` +edit the css file you made and press `ctrl`-`shift`-`R` in the browser to see the changes as you go (no need to restart copyparty for each change) if you are introducing a new ttf/woff font, don't forget to declare the font itself in the css file; here's one of the default fonts from `ui.css`: From 8016e6711bc338167fa219cd7baa92e01293a500 Mon Sep 17 00:00:00 2001 From: ed Date: Mon, 26 Feb 2024 22:13:40 +0000 Subject: [PATCH 06/22] md-sandbox: fix css url rewriter; closes #74 `@import url(https://...)` would get rewritten to baseURL + https://... also reorder the generated csstext so that @imports appear first; necessary for stuff like googlefonts to take effect --- copyparty/web/browser.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 03fb2339..4435b7e3 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -7569,12 +7569,25 @@ var globalcss = (function () { var css = ds[b].cssText.split(/\burl\(/g); ret += css[0]; for (var c = 1; c < css.length; c++) { - var delim = (/^["']/.exec(css[c])) ? css[c].slice(0, 1) : ''; - ret += 'url(' + delim + ((css[c].slice(0, 8).indexOf('://') + 1 || css[c].startsWith('/')) ? '' : base) + - css[c].slice(delim ? 1 : 0); + var m = /(^ *["']?)(.*)/.exec(css[c]), + delim = m[1], + ctxt = m[2], + is_abs = /^\/|[^)/:]+:\/\//.exec(ctxt); + + ret += 'url(' + delim + (is_abs ? '' : base) + ctxt; } ret += '\n'; } + if (ret.indexOf('\n@import') + 1) { + var c0 = ret.split('\n'), + c1 = [], + c2 = []; + + for (var a = 0; a < c0.length; a++) + (c0[a].startsWith('@import') ? c1 : c2).push(c0[a]); + + ret = c1.concat(c2).join('\n'); + } } catch (ex) { console.log('could not read css', a, base); From 055302b5be6f77a3d28f5d93f3bc2f2e24dcf76d Mon Sep 17 00:00:00 2001 From: ed Date: Mon, 26 Feb 2024 22:31:28 +0000 Subject: [PATCH 07/22] faq: repairing firefox certstore corruption --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 675a66f3..2d69df77 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,9 @@ upgrade notes * can I make copyparty download a file to my server if I give it a URL? * yes, using [hooks](https://github.com/9001/copyparty/blob/hovudstraum/bin/hooks/wget.py) +* firefox refuses to connect over https, saying "Secure Connection Failed" or "SEC_ERROR_BAD_SIGNATURE", but the usual button to "Accept the Risk and Continue" is not shown + * firefox has corrupted its certstore; fix this by exiting firefox, then find and delete the file named `cert9.db` somewhere in your firefox profile folder + * i want to learn python and/or programming and am considering looking at the copyparty source code in that occasion * ```bash _| _ __ _ _|_ From 8413ed6d1f1d616eac2ff7b98945cfab5510f2c1 Mon Sep 17 00:00:00 2001 From: ed Date: Mon, 26 Feb 2024 23:51:46 +0000 Subject: [PATCH 08/22] add toggle to disable autoplay on page load --- copyparty/web/browser.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 4435b7e3..89a3490a 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -240,13 +240,14 @@ var Ls = { "ml_drc": "dynamic range compressor", "mt_shuf": "shuffle the songs in each folder\">🔀", + "mt_aplay": "autoplay if there is a song-ID in the link you clicked to access the server$N$Ndisabling this will also stop the page URL from being updated with song-IDs when playing music, to prevent autoplay if these settings are lost but the URL remains\">a▶", "mt_preload": "start loading the next song near the end for gapless playback\">preload", "mt_prescan": "go to the next folder before the last song$Nends, keeping the webbrowser happy$Nso it doesn't stop the playback\">nav", "mt_fullpre": "try to preload the entire song;$N✅ enable on unreliable connections,$N❌ disable on slow connections probably\">full", "mt_waves": "waveform seekbar:$Nshow audio amplitude in the scrubber\">~s", "mt_npclip": "show buttons for clipboarding the currently playing song\">/np", "mt_octl": "os integration (media hotkeys / osd)\">os-ctl", - "mt_oseek": "allow seeking through os integration\">seek", + "mt_oseek": "allow seeking through os integration$N$Nnote: on some devices (iPhones),$Nthis replaces the next-song button\">seek", "mt_oscv": "show album cover in osd\">art", "mt_follow": "keep the playing track scrolled into view\">🎯", "mt_compact": "compact controls\">⟎", @@ -737,13 +738,14 @@ var Ls = { "ml_drc": "compressor (volum-utjevning)", "mt_shuf": "sangene i hver mappe$Nspilles i tilfeldig rekkefĂžlge\">🔀", + "mt_aplay": "forsĂžk Ă„ starte avspilling hvis linken du klikket pĂ„ for Ă„ Ă„pne nettsiden inneholder en sang-ID$N$Nhvis denne deaktiveres sĂ„ vil heller ikke nettside-URLen bli oppdatert med sang-ID'er nĂ„r musikk spilles, i tilfelle innstillingene skulle gĂ„ tapt og nettsiden lastes pĂ„ ny\">a▶", "mt_preload": "hent ned litt av neste sang i forkant,$Nslik at pausen i overgangen blir mindre\">forles", "mt_prescan": "ved behov, bla til neste mappe$Nslik at nettleseren lar oss$Nfortsette Ă„ spille musikk\">bla", "mt_fullpre": "hent ned hele neste sang, ikke bare litt:$N✅ skru pĂ„ hvis nettet ditt er ustabilt,$N❌ skru av hvis nettet ditt er tregt\">full", "mt_waves": "waveform seekbar:$Nvis volumkurve i avspillingsfeltet\">~s", "mt_npclip": "vis knapper for Ă„ kopiere info om sangen du hĂžrer pĂ„\">/np", "mt_octl": "integrering med operativsystemet (fjernkontroll, info-skjerm)\">os-ctl", - "mt_oseek": "tillat spoling med fjernkontroll\">spoling", + "mt_oseek": "tillat spoling med fjernkontroll$N$Nmerk: pĂ„ noen enheter (iPhones) sĂ„ vil$Ndette erstatte knappen for neste sang\">spoling", "mt_oscv": "vis album-cover pĂ„ infoskjermen\">bilde", "mt_follow": "bla slik at sangen som spilles alltid er synlig\">🎯", "mt_compact": "tettpakket avspillerpanel\">⟎", @@ -1401,6 +1403,7 @@ var mpl = (function () { ebi('op_player').innerHTML = ( '

' + L.cl_opts + '

' + ' str: - if self.reloading: - return "cannot reload; already in progress" + with self.up2k.mutex: + if self.reloading: + return "cannot reload; already in progress" + self.reloading = 1 - self.reloading = True Daemon(self._reload, "reloading") return "reload initiated" def _reload(self) -> None: - self.log("root", "reload scheduled") with self.up2k.mutex: + if self.reloading != 1: + return + self.reloading = 2 + self.log("root", "reloading config") self.asrv.reload() self.up2k.reload() self.broker.reload() - - self.reloading = False + self.reloading = 0 def stop_thr(self) -> None: while not self.stop_req: diff --git a/copyparty/up2k.py b/copyparty/up2k.py index af834394..9abcfd7a 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -200,10 +200,10 @@ class Up2k(object): Daemon(self.deferred_init, "up2k-deferred-init") def reload(self) -> None: - self.gid += 1 - self.log("reload #{} initiated".format(self.gid)) + """mutex me""" + self.log("reload #{} scheduled".format(self.gid + 1)) all_vols = self.asrv.vfs.all_vols - self.rescan(all_vols, list(all_vols.keys()), True, False) + self._rescan(all_vols, list(all_vols.keys()), True, False) def deferred_init(self) -> None: all_vols = self.asrv.vfs.all_vols @@ -232,7 +232,7 @@ class Up2k(object): for n in range(max(1, self.args.mtag_mt)): Daemon(self._tagger, "tagger-{}".format(n)) - Daemon(self._run_all_mtp, "up2k-mtp-init") + Daemon(self._run_all_mtp, "up2k-mtp-init", (self.gid,)) def log(self, msg: str, c: Union[int, str] = 0) -> None: if self.pp: @@ -337,14 +337,21 @@ class Up2k(object): def rescan( self, all_vols: dict[str, VFS], scan_vols: list[str], wait: bool, fscan: bool ) -> str: + with self.mutex: + return self._rescan(all_vols, scan_vols, wait, fscan) + + def _rescan( + self, all_vols: dict[str, VFS], scan_vols: list[str], wait: bool, fscan: bool + ) -> str: + """mutex me""" if not wait and self.pp: return "cannot initiate; scan is already in progress" - args = (all_vols, scan_vols, fscan) + self.gid += 1 Daemon( self.init_indexes, "up2k-rescan-{}".format(scan_vols[0] if scan_vols else "all"), - args, + (all_vols, scan_vols, fscan, self.gid), ) return "" @@ -575,19 +582,32 @@ class Up2k(object): return True, ret def init_indexes( - self, all_vols: dict[str, VFS], scan_vols: list[str], fscan: bool + self, all_vols: dict[str, VFS], scan_vols: list[str], fscan: bool, gid: int = 0 ) -> bool: - gid = self.gid - while self.pp and gid == self.gid: - time.sleep(0.1) + if not gid: + with self.mutex: + gid = self.gid - if gid != self.gid: - return False + nspin = 0 + while True: + nspin += 1 + if nspin > 1: + time.sleep(0.1) + + with self.mutex: + if gid != self.gid: + return False + + if self.pp: + continue + + self.pp = ProgressPrinter(self.log, self.args) + + break if gid: - self.log("reload #{} running".format(self.gid)) + self.log("reload #%d running" % (gid,)) - self.pp = ProgressPrinter(self.log, self.args) vols = list(all_vols.values()) t0 = time.time() have_e2d = False @@ -775,7 +795,7 @@ class Up2k(object): if self.mtag: t = "online (running mtp)" if scan_vols: - thr = Daemon(self._run_all_mtp, "up2k-mtp-scan", r=False) + thr = Daemon(self._run_all_mtp, "up2k-mtp-scan", (gid,), r=False) else: self.pp = None t = "online, idle" @@ -1809,8 +1829,7 @@ class Up2k(object): self.pending_tags = [] return ret - def _run_all_mtp(self) -> None: - gid = self.gid + def _run_all_mtp(self, gid: int) -> None: t0 = time.time() for ptop, flags in self.flags.items(): if "mtp" in flags: From 8ca996e2f78091fd7d6655baded31ba9e8e0831b Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 29 Feb 2024 21:21:41 +0000 Subject: [PATCH 10/22] as seen on codeberg --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d69df77..e6d1dd92 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [sfx](#sfx) - the self-contained "binary" * [copyparty.exe](#copypartyexe) - download [copyparty.exe](https://github.com/9001/copyparty/releases/latest/download/copyparty.exe) (win8+) or [copyparty32.exe](https://github.com/9001/copyparty/releases/latest/download/copyparty32.exe) (win7+) * [install on android](#install-on-android) -* [reporting bugs](#reporting-bugs) - ideas for context to include in bug reports +* [reporting bugs](#reporting-bugs) - ideas for context to include, and where to submit them * [devnotes](#devnotes) - for build instructions etc, see [./docs/devnotes.md](./docs/devnotes.md) @@ -286,6 +286,9 @@ roughly sorted by chance of encounter * cannot index non-ascii filenames with `-e2d` * cannot handle filenames with mojibake +if you have a new exciting bug to share, see [reporting bugs](#reporting-bugs) + + ## not my bugs same order here too @@ -1948,7 +1951,12 @@ if you want thumbnails (photos+videos) and you're okay with spending another 132 # reporting bugs -ideas for context to include in bug reports +ideas for context to include, and where to submit them + +please get in touch using any of the following URLs: +* https://github.com/9001/copyparty/ **(primary)** +* https://gitlab.com/9001/copyparty/ *(mirror)* +* https://codeberg.org/9001/copyparty *(mirror)* in general, commandline arguments (and config file if any) From d744f3ff8fc0dde20aa29c09375b929ca77d349f Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 7 Mar 2024 19:47:38 +0000 Subject: [PATCH 11/22] improve smoketests, warnings and error-messages: * docker: warn if there are config-files in ~/.config/copyparty because somebody copied their config into /cfg/copyparty instead of /cfg as intended * docker: warn if there are no config-files in an included directory * make misconfigured reverse-proxies more obvious * explain cors rejections in server log * indicate cors rejection in error toast --- copyparty/__main__.py | 2 +- copyparty/authsrv.py | 44 +++++++++++++++++++++++++++++++------------ copyparty/httpcli.py | 8 ++++++-- copyparty/svchub.py | 15 ++++++++++++++- copyparty/web/util.js | 10 +++++++--- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 60bb6919..21371443 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -395,7 +395,7 @@ def configure_ssl_ciphers(al: argparse.Namespace) -> None: def args_from_cfg(cfg_path: str) -> list[str]: lines: list[str] = [] - expand_config_file(lines, cfg_path, "") + expand_config_file(None, lines, cfg_path, "") lines = upgrade_cfg_fmt(None, argparse.Namespace(vc=False), lines, "") ret: list[str] = [] diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index df945b63..cf7b066a 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -863,7 +863,7 @@ class AuthSrv(object): ) -> None: self.line_ctr = 0 - expand_config_file(cfg_lines, fp, "") + expand_config_file(self.log, cfg_lines, fp, "") if self.args.vc: lns = ["{:4}: {}".format(n, s) for n, s in enumerate(cfg_lines, 1)] self.log("expanded config file (unprocessed):\n" + "\n".join(lns)) @@ -2101,27 +2101,47 @@ def split_cfg_ln(ln: str) -> dict[str, Any]: return ret -def expand_config_file(ret: list[str], fp: str, ipath: str) -> None: +def expand_config_file(log: Optional["NamedLogger"], ret: list[str], fp: str, ipath: str) -> None: """expand all % file includes""" fp = absreal(fp) if len(ipath.split(" -> ")) > 64: raise Exception("hit max depth of 64 includes") if os.path.isdir(fp): - names = os.listdir(fp) - crumb = "#\033[36m cfg files in {} => {}\033[0m".format(fp, names) - ret.append(crumb) - for fn in sorted(names): + names = list(sorted(os.listdir(fp))) + cnames = [x for x in names if x.lower().endswith(".conf")] + if not cnames: + t = "warning: tried to read config-files from folder '%s' but it does not contain any " + if names: + t += ".conf files; the following files were ignored: %s" + t = t % (fp, ", ".join(names[:8])) + else: + t += "files at all" + t = t % (fp,) + + if log: + log(t, 3) + + ret.append("#\033[33m %s\033[0m" % (t,)) + else: + zs = "#\033[36m cfg files in %s => %s\033[0m" % (fp, cnames) + ret.append(zs) + + for fn in cnames: fp2 = os.path.join(fp, fn) - if not fp2.endswith(".conf") or fp2 in ipath: + if fp2 in ipath: continue - expand_config_file(ret, fp2, ipath) + expand_config_file(log, ret, fp2, ipath) - if ret[-1] == crumb: - # no config files below; remove breadcrumb - ret.pop() + return + if not os.path.exists(fp): + t = "warning: tried to read config from '%s' but the file/folder does not exist" % (fp,) + if log: + log(t, 3) + + ret.append("#\033[31m %s\033[0m" % (t,)) return ipath += " -> " + fp @@ -2135,7 +2155,7 @@ def expand_config_file(ret: list[str], fp: str, ipath: str) -> None: fp2 = ln[1:].strip() fp2 = os.path.join(os.path.dirname(fp), fp2) ofs = len(ret) - expand_config_file(ret, fp2, ipath) + expand_config_file(log, ret, fp2, ipath) for n in range(ofs, len(ret)): ret[n] = pad + ret[n] continue diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 88b7c215..0cf531ed 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -518,9 +518,13 @@ class HttpCli(object): return self.handle_options() and self.keepalive if not cors_k: + host = self.headers.get("host", "") origin = self.headers.get("origin", "") - self.log("cors-reject {} from {}".format(self.mode, origin), 3) - raise Pebkac(403, "no surfing") + proto = "https://" if self.is_https else "http://" + guess = "modifying" if (origin and host) else "stripping" + t = "cors-reject %s because request-header Origin='%s' does not match request-protocol '%s' and host '%s' based on request-header Host='%s' (note: if this request is not malicious, check if your reverse-proxy is accidentally %s request headers, in particular 'Origin', for example by running copyparty with --ihead='*' to show all request headers)" + self.log(t % (self.mode, origin, proto, self.host, host, guess), 3) + raise Pebkac(403, "rejected by cors-check") # getattr(self.mode) is not yet faster than this if self.mode == "POST": diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 008cf5eb..223d8afd 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -28,7 +28,7 @@ if True: # pylint: disable=using-constant-test import typing from typing import Any, Optional, Union -from .__init__ import ANYWIN, EXE, MACOS, TYPE_CHECKING, EnvParams, unicode +from .__init__ import ANYWIN, E, EXE, MACOS, TYPE_CHECKING, EnvParams, unicode from .authsrv import BAD_CFG, AuthSrv from .cert import ensure_cert from .mtag import HAVE_FFMPEG, HAVE_FFPROBE @@ -154,6 +154,8 @@ class SvcHub(object): lg.handlers = [lh] lg.setLevel(logging.DEBUG) + self._check_env() + if args.stackmon: start_stackmon(args.stackmon, 0) @@ -385,6 +387,17 @@ class SvcHub(object): Daemon(self.sd_notify, "sd-notify") + def _check_env(self) -> None: + try: + files = os.listdir(E.cfg) + except: + files = [] + + hits = [x for x in files if x.lower().endswith(".conf")] + if hits: + t = "WARNING: found config files in [%s]: %s\n config files are not expected here, and will NOT be loaded (unless your setup is intentionally hella funky)" + self.log("root", t % (E.cfg, ", ".join(hits)), 3) + def _process_config(self) -> bool: al = self.args diff --git a/copyparty/web/util.js b/copyparty/web/util.js index 1faf73be..f654a0a6 100644 --- a/copyparty/web/util.js +++ b/copyparty/web/util.js @@ -1995,15 +1995,19 @@ function xhrchk(xhr, prefix, e404, lvl, tag) { if (tag === undefined) tag = prefix; - var errtxt = (xhr.response && xhr.response.err) || xhr.responseText, + var errtxt = ((xhr.response && xhr.response.err) || xhr.responseText) || '', + suf = '', fun = toast[lvl || 'err'], is_cf = /[Cc]loud[f]lare|>Just a mo[m]ent|#cf-b[u]bbles|Chec[k]ing your br[o]wser|\/chall[e]nge-platform|"chall[e]nge-error|nable Ja[v]aScript and cook/.test(errtxt); + if (errtxt.startsWith('
'))
+        suf = '\n\nerror-details: «' + errtxt.slice(5).split('\n')[0].trim() + '»';
+
     if (xhr.status == 403 && !is_cf)
-        return toast.err(0, prefix + (L && L.xhr403 || "403: access denied\n\ntry pressing F5, maybe you got logged out"), tag);
+        return toast.err(0, prefix + (L && L.xhr403 || "403: access denied\n\ntry pressing F5, maybe you got logged out") + suf, tag);
 
     if (xhr.status == 404)
-        return toast.err(0, prefix + e404, tag);
+        return toast.err(0, prefix + e404 + suf, tag);
 
     if (is_cf && (xhr.status == 403 || xhr.status == 503)) {
         var now = Date.now(), td = now - cf_cha_t;

From 8785d2f9feed82633f3ce79eaa37a5d7150c91f2 Mon Sep 17 00:00:00 2001
From: ed 
Date: Fri, 8 Mar 2024 18:20:29 +0000
Subject: [PATCH 12/22] add volflag `sparse` to force use of sparse files;

this improves performance on s3-backed volumes

noktuas reported on discord that the upload performance was
unexpectedly poor when writing to an s3 bucket through a JuiceFS
fuse-mount, only getting 1.5 MiB/s with copyparty, meanwhile a
regular filecopy averaged 30 MiB/s plus

the issue was that s3 does not support sparse files, so copyparty
would fall back to sequential uploading, and also disable fpool,
causing JuiceFS to repeatedly commit the same 5 MiB range to
the storage provider as each chunk arrived from the client

by forcing use of sparse files, s3 adapters such as JuiceFS and
geesefs will "only" write the entire file to s3 *twice*, initially
it writes the full filesize of zerobytes (depending on adapter,
hopefully using gzip compression to reduce the bandwidth necessary)
and then the actual file data in an adapter-specific chunksize

with this volflag, copyparty appears to reach the full expected speed
---
 copyparty/cfg.py  | 1 +
 copyparty/up2k.py | 8 +++++++-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/copyparty/cfg.py b/copyparty/cfg.py
index 10e921e8..abfb6c13 100644
--- a/copyparty/cfg.py
+++ b/copyparty/cfg.py
@@ -116,6 +116,7 @@ flagcats = {
         "hardlink": "does dedup with hardlinks instead of symlinks",
         "neversymlink": "disables symlink fallback; full copy instead",
         "copydupes": "disables dedup, always saves full copies of dupes",
+        "sparse": "force use of sparse files, mainly for s3-backed storage",
         "daw": "enable full WebDAV write support (dangerous);\nPUT-operations will now \033[1;31mOVERWRITE\033[0;35m existing files",
         "nosub": "forces all uploads into the top folder of the vfs",
         "magic": "enables filetype detection for nameless uploads",
diff --git a/copyparty/up2k.py b/copyparty/up2k.py
index 9abcfd7a..372d0c6a 100644
--- a/copyparty/up2k.py
+++ b/copyparty/up2k.py
@@ -3956,7 +3956,13 @@ class Up2k(object):
 
             if not ANYWIN and sprs and sz > 1024 * 1024:
                 fs = self.fstab.get(pdir)
-                if fs != "ok":
+                if fs == "ok":
+                    pass
+                elif "sparse" in self.flags[job["ptop"]]:
+                    t = "volflag 'sparse' is forcing use of sparse files for uploads to [%s]"
+                    self.log(t % (job["ptop"],))
+                    relabel = True
+                else:
                     relabel = True
                     f.seek(1024 * 1024 - 1)
                     f.write(b"e")

From 7741870dc783d025a52ebb1acccf9f28f7ab0230 Mon Sep 17 00:00:00 2001
From: ed 
Date: Fri, 8 Mar 2024 21:33:39 +0000
Subject: [PATCH 13/22] make cloudflare outages non-fatal to uploads

if a reverse-proxy starts hijacking requests and replying with HTML,
don't panic when it fails to decode as a handshake json

fix this for most other json-expecting gizmos too,
and take the opportunity to cleanup some text formatting
---
 copyparty/web/browser.js | 19 ++++++++++++++-----
 copyparty/web/md2.js     |  6 +++---
 copyparty/web/mde.js     |  6 +++---
 copyparty/web/up2k.js    | 13 ++++++++++++-
 copyparty/web/util.js    |  9 +++++++--
 5 files changed, 39 insertions(+), 14 deletions(-)

diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js
index 89a3490a..03907e5a 100644
--- a/copyparty/web/browser.js
+++ b/copyparty/web/browser.js
@@ -389,6 +389,8 @@ var Ls = {
 		"md_eshow": "cannot render ",
 		"md_off": "[📜readme] disabled in [⚙] -- document hidden",
 
+		"badreply": "Failed to parse reply from server",
+
 		"xhr403": "403: Access denied\n\ntry pressing F5, maybe you got logged out",
 		"cf_ok": "sorry about that -- DD" + wah + "oS protection kicked in\n\nthings should resume in about 30 sec\n\nif nothing happens, hit F5 to reload the page",
 		"tl_xe1": "could not list subfolders:\n\nerror ",
@@ -887,6 +889,8 @@ var Ls = {
 		"md_eshow": "viser forenklet ",
 		"md_off": "[📜readme] er avskrudd i [⚙] -- dokument skjult",
 
+		"badreply": "Ugyldig svar ifra serveren",
+
 		"xhr403": "403: Tilgang nektet\n\nkanskje du ble logget ut? prĂžv Ă„ trykk F5",
 		"cf_ok": "beklager -- liten tilfeldig kontroll, alt OK\n\nting skal fortsette om ca. 30 sekunder\n\nhvis ikkeno skjer, trykk F5 for Ä laste siden pÄ nytt",
 		"tl_xe1": "kunne ikke hente undermapper:\n\nfeil ",
@@ -969,7 +973,7 @@ var Ls = {
 		"u_emtleakf": 'prĂžver fĂžlgende:\n
  • trykk F5 for Ă„ laste siden pĂ„ nytt
  • sĂ„ skru pĂ„ đŸ„” ("enkelt UI") i opplasteren
  • og forsĂžk den samme opplastningen igjen
\nPS: Firefox
fikser forhÄpentligvis feilen en eller annen gang', "u_s404": "ikke funnet pÄ serveren", "u_expl": "forklar", - "u_maxconn": "de fleste nettlesere tillater ikke mer enn 6, men firefox lar deg Þke grensen med connections-per-server in about:config", + "u_maxconn": "de fleste nettlesere tillater ikke mer enn 6, men firefox lar deg Þke grensen med connections-per-server i about:config", "u_tu": '

ADVARSEL: turbo er pĂ„,  avbrutte opplastninger vil muligens ikke oppdages og gjenopptas; hold musepekeren over turbo-knappen for mer info

', "u_ts": '

ADVARSEL: turbo er pĂ„,  sĂžkeresultater kan vĂŠre feil; hold musepekeren over turbo-knappen for mer info

', "u_turbo_c": "turbo er deaktivert i serverkonfigurasjonen", @@ -5554,7 +5558,7 @@ document.onkeydown = function (e) { function xhr_search_results() { if (this.status !== 200) { - var msg = unpre(this.responseText); + var msg = hunpre(this.responseText); srch_msg(true, "http " + this.status + ": " + msg); search_in_progress = 0; return; @@ -7494,7 +7498,7 @@ var msel = (function () { xhrchk(this, L.fd_xe1, L.fd_xe2); if (this.status !== 201) { - sf.textContent = 'error: ' + unpre(this.responseText); + sf.textContent = 'error: ' + hunpre(this.responseText); return; } @@ -7542,7 +7546,7 @@ var msel = (function () { xhrchk(this, L.fsm_xe1, L.fsm_xe2); if (this.status < 200 || this.status > 201) { - sf.textContent = 'error: ' + unpre(this.responseText); + sf.textContent = 'error: ' + hunpre(this.responseText); return; } @@ -7878,7 +7882,12 @@ var unpost = (function () { if (!xhrchk(this, L.fu_xe1, L.fu_xe2)) return ebi('op_unpost').innerHTML = L.fu_xe1; - var res = JSON.parse(this.responseText); + try { + var res = JSON.parse(this.responseText); + } + catch (ex) { + return ebi('op_unpost').innerHTML = '

' + L.badreply + ':

' + unpre(this.responseText); + } if (res.length) { if (res.length == 2000) html.push("

" + L.un_max); diff --git a/copyparty/web/md2.js b/copyparty/web/md2.js index dc2f702b..e54af570 100644 --- a/copyparty/web/md2.js +++ b/copyparty/web/md2.js @@ -368,14 +368,14 @@ function save(e) { function save_cb() { if (this.status !== 200) - return toast.err(0, 'Error! The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^

/, ""));
+        return toast.err(0, 'Error!  The file was NOT saved.\n\nError ' + this.status + ":\n" + unpre(this.responseText));
 
     var r;
     try {
         r = JSON.parse(this.responseText);
     }
     catch (ex) {
-        return toast.err(0, 'Failed to parse reply from server:\n\n' + this.responseText);
+        return toast.err(0, 'Error!  The file was likely NOT saved.\n\nFailed to parse reply from server:\n\n' + unpre(this.responseText));
     }
 
     if (!r.ok) {
@@ -418,7 +418,7 @@ function run_savechk(lastmod, txt, btn, ntry) {
 
 function savechk_cb() {
     if (this.status !== 200)
-        return toast.err(0, 'Error!  The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^
/, ""));
+        return toast.err(0, 'Error!  The file was NOT saved.\n\nError ' + this.status + ":\n" + unpre(this.responseText));
 
     var doc1 = this.txt.replace(/\r\n/g, "\n");
     var doc2 = this.responseText.replace(/\r\n/g, "\n");
diff --git a/copyparty/web/mde.js b/copyparty/web/mde.js
index f8de0feb..5c2872df 100644
--- a/copyparty/web/mde.js
+++ b/copyparty/web/mde.js
@@ -134,14 +134,14 @@ function save(mde) {
 
 function save_cb() {
     if (this.status !== 200)
-        return toast.err(0, 'Error!  The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^
/, ""));
+        return toast.err(0, 'Error!  The file was NOT saved.\n\nError ' + this.status + ":\n" + unpre(this.responseText));
 
     var r;
     try {
         r = JSON.parse(this.responseText);
     }
     catch (ex) {
-        return toast.err(0, 'Failed to parse reply from server:\n\n' + this.responseText);
+        return toast.err(0, 'Error!  The file was likely NOT saved.\n\nFailed to parse reply from server:\n\n' + unpre(this.responseText));
     }
 
     if (!r.ok) {
@@ -180,7 +180,7 @@ function save_cb() {
 
 function save_chk() {
     if (this.status !== 200)
-        return toast.err(0, 'Error!  The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^
/, ""));
+        return toast.err(0, 'Error!  The file was NOT saved.\n\nError ' + this.status + ":\n" + unpre(this.responseText));
 
     var doc1 = this.txt.replace(/\r\n/g, "\n");
     var doc2 = this.responseText.replace(/\r\n/g, "\n");
diff --git a/copyparty/web/up2k.js b/copyparty/web/up2k.js
index 5386d5eb..a0c71638 100644
--- a/copyparty/web/up2k.js
+++ b/copyparty/web/up2k.js
@@ -2256,6 +2256,7 @@ function up2k_init(subtle) {
             console.log('handshake onerror, retrying', t.name, t);
             apop(st.busy.handshake, t);
             st.todo.handshake.unshift(t);
+            t.cooldown = Date.now() + 5000 + Math.floor(Math.random() * 3000);
             t.keepalive = keepalive;
         };
         var orz = function (e) {
@@ -2263,16 +2264,26 @@ function up2k_init(subtle) {
                 return console.log('zombie handshake onload', t.name, t);
 
             if (xhr.status == 200) {
+                try {
+                    var response = JSON.parse(xhr.responseText);
+                }
+                catch (ex) {
+                    apop(st.busy.handshake, t);
+                    st.todo.handshake.unshift(t);
+                    t.cooldown = Date.now() + 5000 + Math.floor(Math.random() * 3000);
+                    return toast.err(0, 'Handshake error; will retry...\n\n' + L.badreply + ':\n\n' + unpre(xhr.responseText));
+                }
+
                 t.t_handshake = Date.now();
                 if (keepalive) {
                     apop(st.busy.handshake, t);
+                    tasker();
                     return;
                 }
 
                 if (toast.tag === t)
                     toast.ok(5, L.u_fixed);
 
-                var response = JSON.parse(xhr.responseText);
                 if (!response.name) {
                     var msg = '',
                         smsg = '';
diff --git a/copyparty/web/util.js b/copyparty/web/util.js
index f654a0a6..c7e5e44f 100644
--- a/copyparty/web/util.js
+++ b/copyparty/web/util.js
@@ -1417,9 +1417,12 @@ function lf2br(txt) {
 }
 
 
-function unpre(txt) {
+function hunpre(txt) {
     return ('' + txt).replace(/^
/, '');
 }
+function unpre(txt) {
+    return esc(hunpre(txt));
+}
 
 
 var toast = (function () {
@@ -2001,7 +2004,9 @@ function xhrchk(xhr, prefix, e404, lvl, tag) {
         is_cf = /[Cc]loud[f]lare|>Just a mo[m]ent|#cf-b[u]bbles|Chec[k]ing your br[o]wser|\/chall[e]nge-platform|"chall[e]nge-error|nable Ja[v]aScript and cook/.test(errtxt);
 
     if (errtxt.startsWith('
'))
-        suf = '\n\nerror-details: «' + errtxt.slice(5).split('\n')[0].trim() + '»';
+        suf = '\n\nerror-details: «' + unpre(errtxt).split('\n')[0].trim() + '»';
+    else
+        errtxt = esc(errtxt).slice(0, 32768);
 
     if (xhr.status == 403 && !is_cf)
         return toast.err(0, prefix + (L && L.xhr403 || "403: access denied\n\ntry pressing F5, maybe you got logged out") + suf, tag);

From 547a48638769715e39ab17c5601c0474771dbf19 Mon Sep 17 00:00:00 2001
From: ed 
Date: Fri, 8 Mar 2024 21:55:07 +0000
Subject: [PATCH 14/22] defer final up2k redraw until dedups resolved

fixes busy-tab still showing dupes as rejected
---
 copyparty/web/up2k.js | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/copyparty/web/up2k.js b/copyparty/web/up2k.js
index a0c71638..12bce623 100644
--- a/copyparty/web/up2k.js
+++ b/copyparty/web/up2k.js
@@ -1722,8 +1722,6 @@ function up2k_init(subtle) {
                         ebi('u2etas').style.textAlign = 'left';
                     }
                     etafun();
-                    if (pvis.act == 'bz')
-                        pvis.changecard('bz');
                 }
 
                 if (flag) {
@@ -1859,6 +1857,9 @@ function up2k_init(subtle) {
         timer.rm(donut.do);
         ebi('u2tabw').style.minHeight = '0px';
         utw_minh = 0;
+
+        if (pvis.act == 'bz')
+            pvis.changecard('bz');
     }
 
     function chill(t) {

From a1ad608267c3f90d224784e5fd2468e73f02eb39 Mon Sep 17 00:00:00 2001
From: ed 
Date: Sat, 9 Mar 2024 09:02:16 +0000
Subject: [PATCH 15/22] add TODO.md, closes #78

---
 README.md      |  9 +++++++++
 docs/README.md |  3 +++
 docs/TODO.md   | 35 +++++++++++++++++++++++++++++++++++
 3 files changed, 47 insertions(+)
 create mode 100644 docs/TODO.md

diff --git a/README.md b/README.md
index e6d1dd92..4ba6b60b 100644
--- a/README.md
+++ b/README.md
@@ -344,6 +344,12 @@ upgrade notes
   * yes, using the [`g` permission](#accounts-and-volumes), see the examples there
   * you can also do this with linux filesystem permissions; `chmod 111 music` will make it possible to access files and folders inside the `music` folder but not list the immediate contents -- also works with other software, not just copyparty
 
+* can I link someone to a password-protected volume/file by including the password in the URL?
+  * yes, by adding `?pw=hunter2` to the end; replace `?` with `&` if there are parameters in the URL already, meaning it contains a `?` near the end
+
+* how do I stop `.hist` folders from appearing everywhere on my HDD?
+  * by default, a `.hist` folder is created inside each volume for the filesystem index, thumbnails, audio transcodes, and markdown document history. Use the `--hist` global-option or the `hist` volflag to move it somewhere else; see [database location](#database-location)
+
 * can I make copyparty download a file to my server if I give it a URL?
   * yes, using [hooks](https://github.com/9001/copyparty/blob/hovudstraum/bin/hooks/wget.py)
 
@@ -1971,3 +1977,6 @@ if there's a wall of base64 in the log (thread stacks) then please include that,
 # devnotes
 
 for build instructions etc, see [./docs/devnotes.md](./docs/devnotes.md)
+
+see [./docs/TODO.md](./docs/TODO.md) for planned features / fixes / changes
+
diff --git a/docs/README.md b/docs/README.md
index 4f3339e5..3086e534 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,9 @@
 
 # other stuff
 
+## [`TODO.md`](TODO.md)
+* planned features / fixes / changes
+
 ## [`example.conf`](example.conf)
 * example config file for `-c`
 
diff --git a/docs/TODO.md b/docs/TODO.md
new file mode 100644
index 00000000..7643eb3e
--- /dev/null
+++ b/docs/TODO.md
@@ -0,0 +1,35 @@
+a living list of upcoming features / fixes / changes, very roughly in order of priority
+
+* readme / docs
+  * docker ftp config
+  * custom-fonts (copy from issue)
+  * s3 speedfix
+  * reverseproxy/cloudflare: ensure cloudflare does not terminate https
+  * docker: suggest putting hists in /cfg/hists/
+
+* [github issue #62](https://github.com/9001/copyparty/issues/62) - IdP / single-sign-on powered by a local identity provider service which is possibly hooked up to ldap or an oauth service
+  * secret token header between reverse-proxy and copyparty to confirm the headers are legit
+  * persist autogenerated volumes for db-init + nullmapping on next startup (`_map_volume` += `only_if_exist`)
+  * sanchk that autogenerated volumes below inaccessible parent
+  * disable logout links if idp detected
+
+* [github discussion #77](https://github.com/9001/copyparty/discussions/77) - cancel-buttons for uploads
+  * definitely included in the unpost list
+  * probably an X-button next to each progressbar
+
+* download accelerator
+  * definitely download chunks in parallel
+  * maybe resumable downloads (chrome-only, jank api)
+  * maybe checksum validation (return sha512 of requested range in responses, and probably also warks)
+
+* [github issue #64](https://github.com/9001/copyparty/issues/64) - dirkeys 2nd season
+  * popular feature request, finally time to refactor browser.js i suppose...
+
+* [github issue #37](https://github.com/9001/copyparty/issues/37) - upload PWA
+  * or [maybe not](https://arstechnica.com/tech-policy/2024/02/apple-under-fire-for-disabling-iphone-web-apps-eu-asks-developers-to-weigh-in/), or [maybe](https://arstechnica.com/gadgets/2024/03/apple-changes-course-will-keep-iphone-eu-web-apps-how-they-are-in-ios-17-4/)
+
+* [github issue #57](https://github.com/9001/copyparty/issues/57) - config GUI
+  * configs given to -c can be ordered with numerical prefix
+  * autorevert settings if it fails to apply
+  * countdown until session invalidates in settings gui, with refresh-button
+

From 1c011ff0bbd2153a1ca59674274efc61c2e477e7 Mon Sep 17 00:00:00 2001
From: ed 
Date: Sat, 9 Mar 2024 17:50:24 +0000
Subject: [PATCH 16/22] hide k304 config from controlpanel by default;

as this option is very rarely useful, add global-option `--k304` to
unhide the button and/or set it default-enabled

the toggle will still appear when the feature was previously enabled by
a client, and the feature is still default-enabled for all IE clients
---
 copyparty/__main__.py     | 1 +
 copyparty/httpcli.py      | 9 +++++++--
 copyparty/web/splash.html | 4 +++-
 3 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index 21371443..437ddd65 100755
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -1269,6 +1269,7 @@ def add_ui(ap, retry):
     ap2.add_argument("--bname", metavar="TXT", type=u, default="--name", help="server name (displayed in filebrowser document title)")
     ap2.add_argument("--pb-url", metavar="URL", type=u, default="https://github.com/9001/copyparty", help="powered-by link; disable with \033[33m-np\033[0m")
     ap2.add_argument("--ver", action="store_true", help="show version on the control panel (incompatible with \033[33m-nb\033[0m)")
+    ap2.add_argument("--k304", metavar="NUM", type=int, default=0, help="configure the option to enable/disable k304 on the controlpanel (workaround for buggy reverse-proxies); [\033[32m0\033[0m] = hidden and default-off, [\033[32m1\033[0m] = visible and default-off, [\033[32m2\033[0m] = visible and default-on")
     ap2.add_argument("--md-sbf", metavar="FLAGS", type=u, default="downloads forms popups scripts top-navigation-by-user-activation", help="list of capabilities to ALLOW for README.md docs (volflag=md_sbf); see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox")
     ap2.add_argument("--lg-sbf", metavar="FLAGS", type=u, default="downloads forms popups scripts top-navigation-by-user-activation", help="list of capabilities to ALLOW for prologue/epilogue docs  (volflag=lg_sbf)")
     ap2.add_argument("--no-sb-md", action="store_true", help="don't sandbox README.md documents (volflags: no_sb_md | sb_md)")
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index 0cf531ed..a97c32ce 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -655,7 +655,11 @@ class HttpCli(object):
 
     def k304(self) -> bool:
         k304 = self.cookies.get("k304")
-        return k304 == "y" or ("; Trident/" in self.ua and not k304)
+        return (
+            k304 == "y"
+            or (self.args.k304 == 2 and k304 != "n")
+            or ("; Trident/" in self.ua and not k304)
+        )
 
     def send_headers(
         self,
@@ -3352,6 +3356,7 @@ class HttpCli(object):
             dbwt=vs["dbwt"],
             url_suf=suf,
             k304=self.k304(),
+            k304vis=self.args.k304 > 0,
             ver=S_VERSION if self.args.ver else "",
             ahttps="" if self.is_https else "https://" + self.host + self.req,
         )
@@ -3360,7 +3365,7 @@ class HttpCli(object):
 
     def set_k304(self) -> bool:
         v = self.uparam["k304"].lower()
-        if v == "y":
+        if v in "yn":
             dur = 86400 * 299
         else:
             dur = 0
diff --git a/copyparty/web/splash.html b/copyparty/web/splash.html
index 73bb6d87..8e3fe668 100644
--- a/copyparty/web/splash.html
+++ b/copyparty/web/splash.html
@@ -78,13 +78,15 @@
 
 		

client config:

    + {% if k304 or k304vis %} {% if k304 %}
  • disable k304 (currently enabled) {%- else %}
  • enable k304 (currently disabled) {% endif %}
    enabling this will disconnect your client on every HTTP 304, which can prevent some buggy proxies from getting stuck (suddenly not loading pages), but it will also make things slower in general
  • - + {% endif %} +
  • reset client settings
From 7f08f10c37e36c565563c1e5c4a598e7a7a056ac Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 9 Mar 2024 20:30:20 +0000 Subject: [PATCH 17/22] stop recommending `--xff-src=any`; running behind cloudflare doesn't necessarily mean being accessible ONLY through cloudflare also include a general warning about optimal configuration for non-cloudflare intermediates --- copyparty/httpcli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index a97c32ce..818498d0 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -319,7 +319,9 @@ class HttpCli(object): if self.args.xff_re and not self.args.xff_re.match(pip): t = 'got header "%s" from untrusted source "%s" claiming the true client ip is "%s" (raw value: "%s"); if you trust this, you must allowlist this proxy with "--xff-src=%s"' if self.headers.get("cf-connecting-ip"): - t += " Alternatively, if you are behind cloudflare, it is better to specify these two instead: --xff-hdr=cf-connecting-ip --xff-src=any" + t += ' Note: if you are behind cloudflare, then this default header is not a good choice; please first make sure your local reverse-proxy (if any) does not allow non-cloudflare IPs from providing cf-* headers, and then add this additional global setting: "--xff-hdr=cf-connecting-ip"' + else: + t += ' Note: depending on your reverse-proxy, and/or WAF, and/or other intermediates, you may want to read the true client IP from another header by also specifying "--xff-hdr=SomeOtherHeader"' zs = ( ".".join(pip.split(".")[:2]) + "." if "." in pip From 2527e90325dd88d3d4a435593b99930575ab316d Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 9 Mar 2024 22:11:35 +0000 Subject: [PATCH 18/22] sharex: backport to v12.1 due to controversial changes in sharex v12.2, something about removing ctrl-scrolling through options while capturing, idk --- contrib/README.md | 2 ++ contrib/sharex12.sxcu | 13 +++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 contrib/sharex12.sxcu diff --git a/contrib/README.md b/contrib/README.md index 1dab4824..01a175f2 100644 --- a/contrib/README.md +++ b/contrib/README.md @@ -16,6 +16,8 @@ * sharex config file to upload screenshots and grab the URL * `RequestURL`: full URL to the target folder * `pw`: password (remove the `pw` line if anon-write) +* the `act:bput` thing is optional since copyparty v1.9.29 +* using an older sharex version, maybe sharex v12.1.1 for example? dw fam i got your back 👉😎👉 [`sharex12.sxcu`](sharex12.sxcu) ### [`send-to-cpp.contextlet.json`](send-to-cpp.contextlet.json) * browser integration, kind of? custom rightclick actions and stuff diff --git a/contrib/sharex12.sxcu b/contrib/sharex12.sxcu new file mode 100644 index 00000000..297eed50 --- /dev/null +++ b/contrib/sharex12.sxcu @@ -0,0 +1,13 @@ +{ + "Name": "copyparty", + "DestinationType": "ImageUploader, TextUploader, FileUploader", + "RequestURL": "http://127.0.0.1:3923/sharex", + "FileFormName": "f", + "Arguments": { + "act": "bput" + }, + "Headers": { + "accept": "url", + "pw": "PUT_YOUR_PASSWORD_HERE_MY_DUDE" + } +} From 0c03921965887d5aafb8b720ea7b3f203bf61dd4 Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 9 Mar 2024 22:12:57 +0000 Subject: [PATCH 19/22] mention that restart is required for changes to global config params in the controlpanel tooltip --- copyparty/web/splash.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/copyparty/web/splash.js b/copyparty/web/splash.js index 63fb4a25..d4195833 100644 --- a/copyparty/web/splash.js +++ b/copyparty/web/splash.js @@ -6,7 +6,7 @@ var Ls = { "d1": "tilstand", "d2": "vis tilstanden til alle trĂ„der", "e1": "last innst.", - "e2": "leser inn konfigurasjonsfiler pĂ„ nytt$N(kontoer, volumer, volumbrytere)$Nog kartlegger alle e2ds-volumer", + "e2": "leser inn konfigurasjonsfiler pĂ„ nytt$N(kontoer, volumer, volumbrytere)$Nog kartlegger alle e2ds-volumer$N$Nmerk: endringer i globale parametere$Nkrever en full restart for Ă„ ta gjenge", "f1": "du kan betrakte:", "g1": "du kan laste opp til:", "cc1": "klient-konfigurasjon", @@ -30,7 +30,7 @@ var Ls = { }, "eng": { "d2": "shows the state of all active threads", - "e2": "reload config files (accounts/volumes/volflags),$Nand rescan all e2ds volumes", + "e2": "reload config files (accounts/volumes/volflags),$Nand rescan all e2ds volumes$N$Nnote: any changes to global settings$Nrequire a full restart to take effect", "u2": "time since the last server write$N( upload / rename / ... )$N$N17d = 17 days$N1h23 = 1 hour 23 minutes$N4m56 = 4 minutes 56 seconds", "v2": "use this server as a local HDD$N$NWARNING: this will show your password!", } From 51a83b04a0365d33330ffeed46b58b8f494926f8 Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 9 Mar 2024 22:14:15 +0000 Subject: [PATCH 20/22] fix upload/filesearch default when preference is not set; ui would enter a confusing state when hopping between a folder with write-permissions and one without --- copyparty/web/up2k.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/copyparty/web/up2k.js b/copyparty/web/up2k.js index 12bce623..38737116 100644 --- a/copyparty/web/up2k.js +++ b/copyparty/web/up2k.js @@ -2868,6 +2868,8 @@ function up2k_init(subtle) { new_state = false; fixed = true; } + if (new_state === undefined) + new_state = can_write ? false : have_up2k_idx ? true : undefined; } if (new_state === undefined) From 3f05b6655c9b28511fdf1c3d2963d80c2cec5efd Mon Sep 17 00:00:00 2001 From: ed Date: Mon, 11 Mar 2024 01:32:02 +0100 Subject: [PATCH 21/22] add UI to abort an unfinished upload; suggested in #77 to abort an upload, refresh the page and access the unpost tab, which now includes unfinished uploads (sorted before completed ones) can be configured through u2abort (global or volflag); by default it requires both the IP and account to match https://a.ocv.me/pub/g/nerd-stuff/2024-0310-stoltzekleiven.jpg --- copyparty/__main__.py | 1 + copyparty/authsrv.py | 9 +++-- copyparty/cfg.py | 2 + copyparty/ftpd.py | 2 +- copyparty/httpcli.py | 25 ++++++++---- copyparty/metrics.py | 3 ++ copyparty/smbd.py | 2 +- copyparty/tftpd.py | 2 +- copyparty/up2k.py | 84 +++++++++++++++++++++++++++++++++------ copyparty/web/browser.css | 4 ++ copyparty/web/browser.js | 58 ++++++++++++++++++++------- docs/TODO.md | 4 -- tests/util.py | 4 +- 13 files changed, 153 insertions(+), 47 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 437ddd65..cd684544 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -871,6 +871,7 @@ def add_upload(ap): ap2.add_argument("--dotpart", action="store_true", help="dotfile incomplete uploads, hiding them from clients unless \033[33m-ed\033[0m") ap2.add_argument("--plain-ip", action="store_true", help="when avoiding filename collisions by appending the uploader's ip to the filename: append the plaintext ip instead of salting and hashing the ip") ap2.add_argument("--unpost", metavar="SEC", type=int, default=3600*12, help="grace period where uploads can be deleted by the uploader, even without delete permissions; 0=disabled, default=12h") + ap2.add_argument("--u2abort", metavar="NUM", type=int, default=1, help="clients can abort incomplete uploads by using the unpost tab (requires \033[33m-e2d\033[0m). [\033[32m0\033[0m] = never allowed (disable feature), [\033[32m1\033[0m] = allow if client has the same IP as the upload AND is using the same account, [\033[32m2\033[0m] = just check the IP, [\033[32m3\033[0m] = just check account-name (volflag=u2abort)") ap2.add_argument("--blank-wt", metavar="SEC", type=int, default=300, help="file write grace period (any client can write to a blank file last-modified more recently than \033[33mSEC\033[0m seconds ago)") ap2.add_argument("--reg-cap", metavar="N", type=int, default=38400, help="max number of uploads to keep in memory when running without \033[33m-e2d\033[0m; roughly 1 MiB RAM per 600") ap2.add_argument("--no-fpool", action="store_true", help="disable file-handle pooling -- instead, repeatedly close and reopen files during upload (bad idea to enable this on windows and/or cow filesystems)") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index cf7b066a..38ea34d1 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -1485,7 +1485,7 @@ class AuthSrv(object): if k not in vol.flags: vol.flags[k] = getattr(self.args, k) - for k in ("nrand",): + for k in ("nrand", "u2abort"): if k in vol.flags: vol.flags[k] = int(vol.flags[k]) @@ -2101,7 +2101,9 @@ def split_cfg_ln(ln: str) -> dict[str, Any]: return ret -def expand_config_file(log: Optional["NamedLogger"], ret: list[str], fp: str, ipath: str) -> None: +def expand_config_file( + log: Optional["NamedLogger"], ret: list[str], fp: str, ipath: str +) -> None: """expand all % file includes""" fp = absreal(fp) if len(ipath.split(" -> ")) > 64: @@ -2137,7 +2139,8 @@ def expand_config_file(log: Optional["NamedLogger"], ret: list[str], fp: str, ip return if not os.path.exists(fp): - t = "warning: tried to read config from '%s' but the file/folder does not exist" % (fp,) + t = "warning: tried to read config from '%s' but the file/folder does not exist" + t = t % (fp,) if log: log(t, 3) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index abfb6c13..9781979f 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -66,6 +66,7 @@ def vf_vmap() -> dict[str, str]: "rm_retry", "sort", "unlist", + "u2abort", "u2ts", ): ret[k] = k @@ -131,6 +132,7 @@ flagcats = { "rand": "force randomized filenames, 9 chars long by default", "nrand=N": "randomized filenames are N chars long", "u2ts=fc": "[f]orce [c]lient-last-modified or [u]pload-time", + "u2abort=1": "allow aborting unfinished uploads? 0=no 1=strict 2=ip-chk 3=acct-chk", "sz=1k-3m": "allow filesizes between 1 KiB and 3MiB", "df=1g": "ensure 1 GiB free disk space", }, diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index 4d72c4b1..4d9c4879 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -300,7 +300,7 @@ class FtpFs(AbstractedFS): vp = join(self.cwd, path).lstrip("/") try: - self.hub.up2k.handle_rm(self.uname, self.h.cli_ip, [vp], [], False) + self.hub.up2k.handle_rm(self.uname, self.h.cli_ip, [vp], [], False, False) except Exception as ex: raise FSE(str(ex)) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 818498d0..a6c20348 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -3550,8 +3550,7 @@ class HttpCli(object): return ret def tx_ups(self) -> bool: - if not self.args.unpost: - raise Pebkac(403, "the unpost feature is disabled in server config") + have_unpost = self.args.unpost and "e2d" in self.vn.flags idx = self.conn.get_u2idx() if not idx or not hasattr(idx, "p_end"): @@ -3570,7 +3569,14 @@ class HttpCli(object): if "fk" in vol.flags and (self.uname in vol.axs.uread or self.uname in vol.axs.upget) } - for vol in self.asrv.vfs.all_vols.values(): + + x = self.conn.hsrv.broker.ask( + "up2k.get_unfinished_by_user", self.uname, self.ip + ) + uret = x.get() + + allvols = self.asrv.vfs.all_vols if have_unpost else {} + for vol in allvols.values(): cur = idx.get_cur(vol.realpath) if not cur: continue @@ -3622,9 +3628,13 @@ class HttpCli(object): for v in ret: v["vp"] = self.args.SR + v["vp"] - jtxt = json.dumps(ret, indent=2, sort_keys=True).encode("utf-8", "replace") - self.log("{} #{} {:.2f}sec".format(lm, len(ret), time.time() - t0)) - self.reply(jtxt, mime="application/json") + if not have_unpost: + ret = [{"kinshi":1}] + + jtxt = '{"u":%s,"c":%s}' % (uret, json.dumps(ret, indent=0)) + zi = len(uret.split('\n"pd":')) - 1 + self.log("%s #%d+%d %.2fsec" % (lm, zi, len(ret), time.time() - t0)) + self.reply(jtxt.encode("utf-8", "replace"), mime="application/json") return True def handle_rm(self, req: list[str]) -> bool: @@ -3639,11 +3649,12 @@ class HttpCli(object): elif self.is_vproxied: req = [x[len(self.args.SR) :] for x in req] + unpost = "unpost" in self.uparam nlim = int(self.uparam.get("lim") or 0) lim = [nlim, nlim] if nlim else [] x = self.conn.hsrv.broker.ask( - "up2k.handle_rm", self.uname, self.ip, req, lim, False + "up2k.handle_rm", self.uname, self.ip, req, lim, False, unpost ) self.loud_reply(x.get()) return True diff --git a/copyparty/metrics.py b/copyparty/metrics.py index 72e86fdb..3af8be9d 100644 --- a/copyparty/metrics.py +++ b/copyparty/metrics.py @@ -206,6 +206,9 @@ class Metrics(object): try: x = self.hsrv.broker.ask("up2k.get_unfinished") xs = x.get() + if not xs: + raise Exception("up2k mutex acquisition timed out") + xj = json.loads(xs) for ptop, (nbytes, nfiles) in xj.items(): tnbytes += nbytes diff --git a/copyparty/smbd.py b/copyparty/smbd.py index 1e97386d..979c11df 100644 --- a/copyparty/smbd.py +++ b/copyparty/smbd.py @@ -340,7 +340,7 @@ class SMB(object): yeet("blocked delete (no-del-acc): " + vpath) vpath = vpath.replace("\\", "/").lstrip("/") - self.hub.up2k.handle_rm(uname, "1.7.6.2", [vpath], [], False) + self.hub.up2k.handle_rm(uname, "1.7.6.2", [vpath], [], False, False) def _utime(self, vpath: str, times: tuple[float, float]) -> None: if not self.args.smbw: diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py index 0020e96a..7b09533a 100644 --- a/copyparty/tftpd.py +++ b/copyparty/tftpd.py @@ -360,7 +360,7 @@ class Tftpd(object): yeet("attempted delete of non-empty file") vpath = vpath.replace("\\", "/").lstrip("/") - self.hub.up2k.handle_rm("*", "8.3.8.7", [vpath], [], False) + self.hub.up2k.handle_rm("*", "8.3.8.7", [vpath], [], False, False) def _access(self, *a: Any) -> bool: return True diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 372d0c6a..5c26e6a5 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -282,9 +282,44 @@ class Up2k(object): } return json.dumps(ret, indent=4) + def get_unfinished_by_user(self, uname, ip) -> str: + if PY2 or not self.mutex.acquire(timeout=2): + return '[{"timeout":1}]' + + ret: list[tuple[int, str, int, int, int]] = [] + try: + for ptop, tab2 in self.registry.items(): + cfg = self.flags.get(ptop, {}).get("u2abort", 1) + if not cfg: + continue + addr = (ip or "\n") if cfg in (1, 2) else "" + user = (uname or "\n") if cfg in (1, 3) else "" + drp = self.droppable.get(ptop, {}) + for wark, job in tab2.items(): + if wark in drp or (user and user != job["user"]) or (addr and addr != job["addr"]): + continue + + zt5 = ( + int(job["t0"]), + djoin(job["vtop"], job["prel"], job["name"]), + job["size"], + len(job["need"]), + len(job["hash"]), + ) + ret.append(zt5) + finally: + self.mutex.release() + + ret.sort(reverse=True) + ret2 = [ + {"at": at, "vp": "/" + vp, "pd": 100 - ((nn * 100) // (nh or 1)), "sz": sz} + for (at, vp, sz, nn, nh) in ret + ] + return json.dumps(ret2, indent=0) + def get_unfinished(self) -> str: if PY2 or not self.mutex.acquire(timeout=0.5): - return "{}" + return "" ret: dict[str, tuple[int, int]] = {} try: @@ -463,7 +498,7 @@ class Up2k(object): if vp: fvp = "%s/%s" % (vp, fvp) - self._handle_rm(LEELOO_DALLAS, "", fvp, [], True) + self._handle_rm(LEELOO_DALLAS, "", fvp, [], True, False) nrm += 1 if nrm: @@ -2690,6 +2725,9 @@ class Up2k(object): a = [job[x] for x in zs.split()] self.db_add(cur, vfs.flags, *a) cur.connection.commit() + elif wark in reg: + # checks out, but client may have hopped IPs + job["addr"] = cj["addr"] if not job: ap1 = djoin(cj["ptop"], cj["prel"]) @@ -3226,7 +3264,7 @@ class Up2k(object): pass def handle_rm( - self, uname: str, ip: str, vpaths: list[str], lim: list[int], rm_up: bool + self, uname: str, ip: str, vpaths: list[str], lim: list[int], rm_up: bool, unpost: bool ) -> str: n_files = 0 ok = {} @@ -3236,7 +3274,7 @@ class Up2k(object): self.log("hit delete limit of {} files".format(lim[1]), 3) break - a, b, c = self._handle_rm(uname, ip, vp, lim, rm_up) + a, b, c = self._handle_rm(uname, ip, vp, lim, rm_up, unpost) n_files += a for k in b: ok[k] = 1 @@ -3250,25 +3288,42 @@ class Up2k(object): return "deleted {} files (and {}/{} folders)".format(n_files, iok, iok + ing) def _handle_rm( - self, uname: str, ip: str, vpath: str, lim: list[int], rm_up: bool + self, uname: str, ip: str, vpath: str, lim: list[int], rm_up: bool, unpost: bool ) -> tuple[int, list[str], list[str]]: self.db_act = time.time() - try: + partial = "" + if not unpost: permsets = [[True, False, False, True]] vn, rem = self.asrv.vfs.get(vpath, uname, *permsets[0]) vn, rem = vn.get_dbv(rem) - unpost = False - except: + else: # unpost with missing permissions? verify with db - if not self.args.unpost: - raise Pebkac(400, "the unpost feature is disabled in server config") - - unpost = True permsets = [[False, True]] vn, rem = self.asrv.vfs.get(vpath, uname, *permsets[0]) vn, rem = vn.get_dbv(rem) + ptop = vn.realpath with self.mutex: - _, _, _, _, dip, dat = self._find_from_vpath(vn.realpath, rem) + abrt_cfg = self.flags.get(ptop, {}).get("u2abort", 1) + addr = (ip or "\n") if abrt_cfg in (1, 2) else "" + user = (uname or "\n") if abrt_cfg in (1, 3) else "" + reg = self.registry.get(ptop, {}) if abrt_cfg else {} + for wark, job in reg.items(): + if (user and user != job["user"]) or (addr and addr != job["addr"]): + continue + if djoin(job["prel"], job["name"]) == rem: + if job["ptop"] != ptop: + t = "job.ptop [%s] != vol.ptop [%s] ??" + raise Exception(t % (job["ptop"] != ptop)) + partial = vn.canonical(vjoin(job["prel"], job["tnam"])) + break + if partial: + dip = ip + dat = time.time() + else: + if not self.args.unpost: + raise Pebkac(400, "the unpost feature is disabled in server config") + + _, _, _, _, dip, dat = self._find_from_vpath(ptop, rem) t = "you cannot delete this: " if not dip: @@ -3361,6 +3416,9 @@ class Up2k(object): cur.connection.commit() wunlink(self.log, abspath, dbv.flags) + if partial: + wunlink(self.log, partial, dbv.flags) + partial = "" if xad: runhook( self.log, diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css index f7a98132..c1e8595f 100644 --- a/copyparty/web/browser.css +++ b/copyparty/web/browser.css @@ -1839,6 +1839,10 @@ html.y #tree.nowrap .ntree a+a:hover { margin: 0; padding: 0; } +#unpost td:nth-child(3), +#unpost td:nth-child(4) { + text-align: right; +} #rui { background: #fff; background: var(--bg); diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 03907e5a..5c9ec3bc 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -102,7 +102,7 @@ var Ls = { "access": " access", "ot_close": "close submenu", "ot_search": "search for files by attributes, path / name, music tags, or any combination of those$N$N<code>foo bar</code> = must contain both «foo» and «bar»,$N<code>foo -bar</code> = must contain «foo» but not «bar»,$N<code>^yana .opus$</code> = start with «yana» and be an «opus» file$N<code>"try unite"</code> = contain exactly «try unite»$N$Nthe date format is iso-8601, like$N<code>2009-12-31</code> or <code>2020-09-12 23:30:00</code>", - "ot_unpost": "unpost: delete your recent uploads", + "ot_unpost": "unpost: delete your recent uploads, or abort unfinished ones", "ot_bup": "bup: basic uploader, even supports netscape 4.0", "ot_mkdir": "mkdir: create a new directory", "ot_md": "new-md: create a new markdown document", @@ -412,7 +412,7 @@ var Ls = { "fz_zipd": "zip with traditional cp437 filenames, for really old software", "fz_zipc": "cp437 with crc32 computed early,$Nfor MS-DOS PKZIP v2.04g (october 1993)$N(takes longer to process before download can start)", - "un_m1": "you can delete your recent uploads below", + "un_m1": "you can delete your recent uploads (or abort unfinished ones) below", "un_upd": "refresh", "un_m4": "or share the files visible below:", "un_ulist": "show", @@ -421,12 +421,15 @@ var Ls = { "un_fclr": "clear filter", "un_derr": 'unpost-delete failed:\n', "un_f5": 'something broke, please try a refresh or hit F5', + "un_nou": 'warning: server too busy to show unfinished uploads; click the "refresh" link in a bit', + "un_noc": 'warning: unpost of fully uploaded files is not enabled/permitted in server config', "un_max": "showing first 2000 files (use the filter)", - "un_avail": "{0} uploads can be deleted", - "un_m2": "sorted by upload time – most recent first:", + "un_avail": "{0} recent uploads can be deleted
{1} unfinished ones can be aborted", + "un_m2": "sorted by upload time; most recent first:", "un_no1": "sike! no uploads are sufficiently recent", "un_no2": "sike! no uploads matching that filter are sufficiently recent", "un_next": "delete the next {0} files below", + "un_abrt": "abort", "un_del": "delete", "un_m3": "loading your recent uploads...", "un_busy": "deleting {0} files...", @@ -912,7 +915,7 @@ var Ls = { "fz_zipd": "zip med filnavn i cp437, for hĂžggamle maskiner", "fz_zipc": "cp437 med tidlig crc32,$Nfor MS-DOS PKZIP v2.04g (oktober 1993)$N(Ăžker behandlingstid pĂ„ server)", - "un_m1": "nedenfor kan du angre / slette filer som du nylig har lastet opp", + "un_m1": "nedenfor kan du angre / slette filer som du nylig har lastet opp, eller avbryte ufullstendige opplastninger", "un_upd": "oppdater", "un_m4": "eller hvis du vil dele nedlastnings-lenkene:", "un_ulist": "vis", @@ -921,12 +924,15 @@ var Ls = { "un_fclr": "nullstill filter", "un_derr": 'unpost-sletting feilet:\n', "un_f5": 'noe gikk galt, prĂžv Ă„ oppdatere listen eller trykk F5', + "un_nou": 'advarsel: kan ikke vise ufullstendige opplastninger akkurat nĂ„; klikk pĂ„ oppdater-linken om litt', + "un_noc": 'advarsel: angring av fullfĂžrte opplastninger er deaktivert i serverkonfigurasjonen', "un_max": "viser de fĂžrste 2000 filene (bruk filteret for Ă„ innsnevre)", - "un_avail": "{0} filer kan slettes", - "un_m2": "sortert etter opplastningstid – nyeste fĂžrst:", + "un_avail": "{0} nylig opplastede filer kan slettes
{1} ufullstendige opplastninger kan avbrytes", + "un_m2": "sortert etter opplastningstid; nyeste fþrst:", "un_no1": "men nei, her var det jaggu ikkeno som slettes kan", "un_no2": "men nei, her var det jaggu ingenting som passet overens med filteret", "un_next": "slett de neste {0} filene nedenfor", + "un_abrt": "avbryt", "un_del": "slett", "un_m3": "henter listen med nylig opplastede filer...", "un_busy": "sletter {0} filer...", @@ -1030,7 +1036,7 @@ modal.load(); ebi('ops').innerHTML = ( '--' + '🔎' + - (have_del && have_unpost ? '🧯' : '') + + (have_del ? '🧯' : '') + '🚀' + '🎈' + '📂' + @@ -7883,19 +7889,38 @@ var unpost = (function () { return ebi('op_unpost').innerHTML = L.fu_xe1; try { - var res = JSON.parse(this.responseText); + var ores = JSON.parse(this.responseText); } catch (ex) { return ebi('op_unpost').innerHTML = '

' + L.badreply + ':

' + unpre(this.responseText); } + + if (ores.u.length == 1 && ores.u[0].timeout) { + html.push('

' + L.un_nou + '

'); + ores.u = []; + } + + if (ores.c.length == 1 && ores.c[0].kinshi) { + html.push('

' + L.un_noc + '

'); + ores.c = []; + } + + for (var a = 0; a < ores.u.length; a++) + ores.u[a].k = 'u'; + + for (var a = 0; a < ores.c.length; a++) + ores.c[a].k = 'c'; + + var res = ores.u.concat(ores.c); + if (res.length) { if (res.length == 2000) html.push("

" + L.un_max); else - html.push("

" + L.un_avail.format(res.length)); + html.push("

" + L.un_avail.format(ores.c.length, ores.u.length)); - html.push(" – " + L.un_m2 + "

"); - html.push(""); + html.push("
" + L.un_m2 + "

"); + html.push("
timesizefile
"); } else html.push('-- ' + (filt.value ? L.un_no2 : L.un_no1) + ''); @@ -7908,10 +7933,13 @@ var unpost = (function () { ''); + + var done = res[a].k == 'c'; html.push( - '' + + '' + '' + - '' + + '' + + (done ? '' : '') + ''); } @@ -7997,7 +8025,7 @@ var unpost = (function () { var xhr = new XHR(); xhr.n = n; xhr.n2 = n2; - xhr.open('POST', SR + '/?delete&lim=' + req.length, true); + xhr.open('POST', SR + '/?delete&unpost&lim=' + req.length, true); xhr.onload = xhr.onerror = unpost_delete_cb; xhr.send(JSON.stringify(req)); }; diff --git a/docs/TODO.md b/docs/TODO.md index 7643eb3e..9227ace5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -13,10 +13,6 @@ a living list of upcoming features / fixes / changes, very roughly in order of p * sanchk that autogenerated volumes below inaccessible parent * disable logout links if idp detected -* [github discussion #77](https://github.com/9001/copyparty/discussions/77) - cancel-buttons for uploads - * definitely included in the unpost list - * probably an X-button next to each progressbar - * download accelerator * definitely download chunks in parallel * maybe resumable downloads (chrome-only, jank api) diff --git a/tests/util.py b/tests/util.py index c64f39a5..e81a6ab9 100644 --- a/tests/util.py +++ b/tests/util.py @@ -119,13 +119,13 @@ class Cfg(Namespace): ex = "ah_cli ah_gen css_browser hist ipa_re js_browser no_forget no_hash no_idx nonsus_urls" ka.update(**{k: None for k in ex.split()}) - ex = "hash_mt srch_time u2j" + ex = "hash_mt srch_time u2abort u2j" ka.update(**{k: 1 for k in ex.split()}) ex = "reg_cap s_thead s_tbody th_convt" ka.update(**{k: 9 for k in ex.split()}) - ex = "db_act df loris re_maxage rproxy rsp_jtr rsp_slp s_wr_slp snap_wri theme themes turbo" + ex = "db_act df k304 loris re_maxage rproxy rsp_jtr rsp_slp s_wr_slp snap_wri theme themes turbo" ka.update(**{k: 0 for k in ex.split()}) ex = "ah_alg bname doctitle exit favico idp_h_usr html_head lg_sbf log_fk md_sbf name textfiles unlist vname R RS SR" From b6554a7f8c6c2b38314a33e9b4988975380caf9f Mon Sep 17 00:00:00 2001 From: ed Date: Mon, 11 Mar 2024 20:18:42 +0000 Subject: [PATCH 22/22] black 3f05b665 (add upload abort feat.) --- copyparty/httpcli.py | 2 +- copyparty/up2k.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index a6c20348..d8c0b9df 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -3629,7 +3629,7 @@ class HttpCli(object): v["vp"] = self.args.SR + v["vp"] if not have_unpost: - ret = [{"kinshi":1}] + ret = [{"kinshi": 1}] jtxt = '{"u":%s,"c":%s}' % (uret, json.dumps(ret, indent=0)) zi = len(uret.split('\n"pd":')) - 1 diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 5c26e6a5..00d06bc2 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -296,7 +296,11 @@ class Up2k(object): user = (uname or "\n") if cfg in (1, 3) else "" drp = self.droppable.get(ptop, {}) for wark, job in tab2.items(): - if wark in drp or (user and user != job["user"]) or (addr and addr != job["addr"]): + if ( + wark in drp + or (user and user != job["user"]) + or (addr and addr != job["addr"]) + ): continue zt5 = ( @@ -3264,7 +3268,13 @@ class Up2k(object): pass def handle_rm( - self, uname: str, ip: str, vpaths: list[str], lim: list[int], rm_up: bool, unpost: bool + self, + uname: str, + ip: str, + vpaths: list[str], + lim: list[int], + rm_up: bool, + unpost: bool, ) -> str: n_files = 0 ok = {} @@ -3321,7 +3331,8 @@ class Up2k(object): dat = time.time() else: if not self.args.unpost: - raise Pebkac(400, "the unpost feature is disabled in server config") + t = "the unpost feature is disabled in server config" + raise Pebkac(400, t) _, _, _, _, dip, dat = self._find_from_vpath(ptop, rem)
timesizedonefile
' + '' + L.un_next.format(Math.min(mods[b], res.length - a)) + '
' + L.un_del + '
' + (done ? L.un_del : L.un_abrt) + '' + unix2iso(res[a].at) + '' + res[a].sz + '' + ('' + res[a].sz).replace(/\B(?=(\d{3})+(?!\d))/g, " ") + '100%' + res[a].pd + '%' + linksplit(res[a].vp).join(' / ') + '