From ef1c55286f020c0f9e35d188987e2694a1ffb9f1 Mon Sep 17 00:00:00 2001
From: ed
Date: Wed, 15 Sep 2021 23:17:02 +0200
Subject: [PATCH] add filekeys
---
README.md | 5 +++++
copyparty/__main__.py | 12 +++++++++++-
copyparty/authsrv.py | 5 +++++
copyparty/httpcli.py | 29 ++++++++++++++++++++++++++---
copyparty/util.py | 10 +++++++++-
5 files changed, 56 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index fcdcc8dc..2323fce6 100644
--- a/README.md
+++ b/README.md
@@ -834,6 +834,11 @@ on public copyparty instances with anonymous upload enabled:
* unless `--no-readme` is set: by uploading/modifying a file named `readme.md`
* if `move` access is granted AND none of `--no-logues`, `--no-dot-mv`, `--no-dot-ren` is set: by uploading some .html file and renaming it to `.epilogue.html` (uploading it directly is blocked)
+other misc:
+
+* you can disable directory listings by giving accesslevel `g` instead of `r`, only accepting direct URLs to files
+ * combine this with volume-flag `c,fk` to generate per-file accesskeys; users which have full read-access will then see URLs with `?k=...` appended to the end, and `g` users must provide that URL including the correct key to avoid a 404
+
## gotchas
diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index de29a921..ee472ca9 100644
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -104,7 +104,7 @@ def ensure_cert():
cert_insec = os.path.join(E.mod, "res/insecure.pem")
cert_cfg = os.path.join(E.cfg, "cert.pem")
if not os.path.exists(cert_cfg):
- shutil.copy2(cert_insec, cert_cfg)
+ shutil.copy(cert_insec, cert_cfg)
try:
if filecmp.cmp(cert_cfg, cert_insec):
@@ -203,6 +203,11 @@ def run_argparse(argv, formatter):
description="http file sharing hub v{} ({})".format(S_VERSION, S_BUILD_DT),
)
+ try:
+ fk_salt = unicode(os.path.getmtime(os.path.join(E.cfg, "cert.pem")))
+ except:
+ fk_salt = "hunter2"
+
sects = [
[
"accounts",
@@ -280,6 +285,10 @@ def run_argparse(argv, formatter):
\033[36mmtp=.bpm=f,audio-bpm.py\033[35m uses the "audio-bpm.py" program to
generate ".bpm" tags from uploads (f = overwrite tags)
\033[36mmtp=ahash,vhash=media-hash.py\033[35m collects two tags at once
+
+ \033[0mothers:
+ \033[36mfk=8\033[35m generates per-file accesskeys,
+ which will then be required at the "g" accesslevel
\033[0m"""
),
],
@@ -361,6 +370,7 @@ def run_argparse(argv, formatter):
ap2 = ap.add_argument_group('safety options')
ap2.add_argument("--ls", metavar="U[,V[,F]]", type=u, help="scan all volumes; arguments USER,VOL,FLAGS; example [**,*,ln,p,r]")
ap2.add_argument("--salt", type=u, default="hunter2", help="up2k file-hash salt")
+ ap2.add_argument("--fk-salt", metavar="SALT", type=u, default=fk_salt, help="per-file accesskey salt")
ap2.add_argument("--no-dot-mv", action="store_true", help="disallow moving dotfiles; makes it impossible to move folders containing dotfiles")
ap2.add_argument("--no-dot-ren", action="store_true", help="disallow renaming dotfiles; makes it impossible to make something a dotfile")
ap2.add_argument("--no-logues", action="store_true", help="disable rendering .prologue/.epilogue.html into directory listings")
diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py
index 296fae07..d6a905ed 100644
--- a/copyparty/authsrv.py
+++ b/copyparty/authsrv.py
@@ -834,6 +834,11 @@ class AuthSrv(object):
if use:
vol.lim = lim
+ for vol in vfs.all_vols.values():
+ fk = vol.flags.get("fk")
+ if fk:
+ vol.flags["fk"] = int(fk) if fk is not True else 8
+
for vol in vfs.all_vols.values():
if "pk" in vol.flags and "gz" not in vol.flags and "xz" not in vol.flags:
vol.flags["gz"] = False # def.pk
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index f5644bda..dbc77327 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -1394,7 +1394,9 @@ class HttpCli(object):
#
# send reply
- if is_compressed or "cache" in self.uparam:
+ if not is_compressed and "cache" not in self.uparam:
+ self.out_headers.update(NO_CACHE)
+ else:
self.out_headers.pop("Cache-Control")
self.out_headers["Accept-Ranges"] = "bytes"
@@ -1617,7 +1619,7 @@ class HttpCli(object):
return True
def tx_404(self):
- m = '404 not found
or maybe you don\'t have access -- try logging in or go home
' + m = 'or maybe you don\'t have access -- try logging in or go home
' html = self.j2("splash", this=self, qvpath=quotep(self.vpath), msg=m) self.reply(html.encode("utf-8"), status=404) return True @@ -1829,6 +1831,15 @@ class HttpCli(object): return self.tx_ico(rem) if not is_dir and (self.can_read or self.can_get): + if not self.can_read and "fk" in vn.flags: + correct = gen_filekey( + self.args.fk_salt, abspath, st.st_size, 0 if ANYWIN else st.st_ino + )[: vn.flags["fk"]] + got = self.uparam.get("k") + if got != correct: + self.log("wrong filekey, want {}, got {}".format(correct, got)) + return self.tx_404() + if abspath.endswith(".md") and "raw" not in self.uparam: return self.tx_md(abspath) @@ -1987,6 +1998,8 @@ class HttpCli(object): idx = self.conn.get_u2idx() icur = idx.get_cur(dbv.realpath) + add_fk = vn.flags.get("fk") + dirs = [] files = [] for fn in vfs_ls: @@ -2032,9 +2045,19 @@ class HttpCli(object): except: ext = "%" + if add_fk: + href = "{}?k={}".format( + quotep(href), + gen_filekey( + self.args.fk_salt, fspath, sz, 0 if ANYWIN else inf.st_ino + )[:add_fk], + ) + else: + href = quotep(href) + item = { "lead": margin, - "href": quotep(href), + "href": href, "name": fn, "sz": sz, "ext": ext, diff --git a/copyparty/util.py b/copyparty/util.py index 32939382..861c9fd6 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -19,7 +19,7 @@ import subprocess as sp # nosec from datetime import datetime from collections import Counter -from .__init__ import PY2, WINDOWS, ANYWIN, VT100 +from .__init__ import PY2, WINDOWS, ANYWIN, VT100, unicode from .stolen import surrogateescape FAKE_MP = False @@ -745,6 +745,14 @@ def read_header(sr): return ret[:ofs].decode("utf-8", "surrogateescape").lstrip("\r\n").split("\r\n") +def gen_filekey(salt, fspath, fsize, inode): + return base64.urlsafe_b64encode( + hashlib.sha512( + "{} {} {} {}".format(salt, fspath, fsize, inode).encode("utf-8", "replace") + ).digest() + ).decode("ascii") + + def humansize(sz, terse=False): for unit in ["B", "KiB", "MiB", "GiB", "TiB"]: if sz < 1024: