From 7c2beba5557a06dd92751b5802549ee54664379b Mon Sep 17 00:00:00 2001 From: ed Date: Sun, 18 Aug 2024 22:49:13 +0000 Subject: [PATCH] add file/folder sharing; closes #84 --- README.md | 28 +++++ copyparty/__main__.py | 10 ++ copyparty/authsrv.py | 126 +++++++++++++++++++++-- copyparty/httpcli.py | 170 +++++++++++++++++++++++++++---- copyparty/httpsrv.py | 12 ++- copyparty/svchub.py | 60 ++++++++++- copyparty/u2idx.py | 22 +++- copyparty/up2k.py | 39 ++++++- copyparty/web/browser.css | 20 ++++ copyparty/web/browser.js | 208 ++++++++++++++++++++++++++++++++++++-- copyparty/web/shares.css | 79 +++++++++++++++ copyparty/web/shares.html | 74 ++++++++++++++ copyparty/web/shares.js | 19 ++++ copyparty/web/splash.html | 6 +- copyparty/web/splash.js | 3 +- copyparty/web/util.js | 18 ++++ docs/devnotes.md | 3 + scripts/sfx.ls | 3 + tests/util.py | 4 +- 19 files changed, 855 insertions(+), 49 deletions(-) create mode 100644 copyparty/web/shares.css create mode 100644 copyparty/web/shares.html create mode 100644 copyparty/web/shares.js diff --git a/README.md b/README.md index 14db39ec..68b03614 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [self-destruct](#self-destruct) - uploads can be given a lifetime * [race the beam](#race-the-beam) - download files while they're still uploading ([demo video](http://a.ocv.me/pub/g/nerd-stuff/cpp/2024-0418-race-the-beam.webm)) * [file manager](#file-manager) - cut/paste, rename, and delete files/folders (if you have permission) + * [shares](#shares) - share a file or folder by creating a temporary link * [batch rename](#batch-rename) - select some files and press `F2` to bring up the rename UI * [media player](#media-player) - plays almost every audio format there is * [audio equalizer](#audio-equalizer) - and [dynamic range compressor](https://en.wikipedia.org/wiki/Dynamic_range_compression) @@ -745,6 +746,33 @@ file selection: click somewhere on the line (not the link itsef), then: you can move files across browser tabs (cut in one tab, paste in another) +## shares + +share a file or folder by creating a temporary link + +when enabled in the server settings (`--shr`), click the bottom-right `share` button to share the folder you're currently in, or select a file first to share only that file + +this feature was made with [identity providers](#identity-providers) in mind -- configure your reverseproxy to skip the IdP's access-control for a given URL prefix and use that to safely share specific files/folders sans the usual auth checks + +when creating a share, the creator can choose any of the following options: + +* password-protection +* expire after a certain time +* allow visitors to upload (if the user who creates the share has write-access) + +semi-intentional limitations: + +* cleanup of expired shares only works when global option `e2d` is set, and/or at least one volume on the server has volflag `e2d` +* only folders from the same volume are shared; if you are sharing a folder which contains other volumes, then the contents of those volumes will not be available +* no option to "delete after first access" because tricky + * when linking something to discord (for example) it'll get accessed by their scraper and that would count as a hit + * browsers wouldn't be able to resume a broken download unless the requester's IP gets allowlisted for X minutes (ref. tricky) + +the links are created inside a specific toplevel folder which must be specified with server-config `--shr`, for example `--shr /share/` (this also enables the feature) + +users can delete their own shares in the controlpanel, and a list of privileged users (`--shr-adm`) are allowed to see and/or delet any share on the server + + ## batch rename select some files and press `F2` to bring up the rename UI diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 373c894c..08e34367 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -972,6 +972,15 @@ def add_fs(ap): ap2.add_argument("--mtab-age", metavar="SEC", type=int, default=60, help="rebuild mountpoint cache every \033[33mSEC\033[0m to keep track of sparse-files support; keep low on servers with removable media") +def add_share(ap): + db_path = os.path.join(E.cfg, "shares.db") + ap2 = ap.add_argument_group('share-url options') + ap2.add_argument("--shr", metavar="URL", default="", help="base url for shared files, for example [\033[32m/share\033[0m] (must be a toplevel subfolder)") + ap2.add_argument("--shr-db", metavar="PATH", default=db_path, help="database to store shares in") + ap2.add_argument("--shr-adm", metavar="U,U", default="", help="comma-separated list of users allowed to view/delete any share") + ap2.add_argument("--shr-v", action="store_true", help="debug") + + def add_upload(ap): ap2 = ap.add_argument_group('upload options') ap2.add_argument("--dotpart", action="store_true", help="dotfile incomplete uploads, hiding them from clients unless \033[33m-ed\033[0m") @@ -1489,6 +1498,7 @@ def run_argparse( add_zc_mdns(ap) add_zc_ssdp(ap) add_fs(ap) + add_share(ap) add_upload(ap) add_db_general(ap, hcores) add_db_metadata(ap) diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 6c4b50c5..d09f7089 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -38,6 +38,7 @@ from .util import ( uncyg, undot, unhumanize, + vjoin, vsplit, ) @@ -342,6 +343,7 @@ class VFS(object): self.histtab: dict[str, str] = {} # all realpath->histpath self.dbv: Optional[VFS] = None # closest full/non-jump parent self.lim: Optional[Lim] = None # upload limits; only set for dbv + self.shr_src: Optional[tuple[VFS, str]] = None # source vfs+rem of a share self.aread: dict[str, list[str]] = {} self.awrite: dict[str, list[str]] = {} self.amove: dict[str, list[str]] = {} @@ -366,6 +368,8 @@ class VFS(object): self.all_aps = [] self.all_vps = [] + self.get_dbv = self._get_dbv + def __repr__(self) -> str: return "VFS(%s)" % ( ", ".join( @@ -527,7 +531,15 @@ class VFS(object): return vn, rem - def get_dbv(self, vrem: str) -> tuple["VFS", str]: + def _get_share_src(self, vrem: str) -> tuple["VFS", str]: + src = self.shr_src + if not src: + return self._get_dbv(vrem) + + shv, srem = src + return shv, vjoin(srem, vrem) + + def _get_dbv(self, vrem: str) -> tuple["VFS", str]: dbv = self.dbv if not dbv: return self, vrem @@ -1354,7 +1366,7 @@ class AuthSrv(object): flags[name] = vals self._e("volflag [{}] += {} ({})".format(name, vals, desc)) - def reload(self) -> None: + def reload(self, verbosity: int = 9) -> None: """ construct a flat list of mountpoints and usernames first from the commandline arguments @@ -1362,9 +1374,9 @@ class AuthSrv(object): before finally building the VFS """ with self.mutex: - self._reload() + self._reload(verbosity) - def _reload(self) -> None: + def _reload(self, verbosity: int = 9) -> None: acct: dict[str, str] = {} # username:password grps: dict[str, list[str]] = {} # groupname:usernames daxs: dict[str, AXS] = {} @@ -1459,9 +1471,8 @@ class AuthSrv(object): vfs = VFS(self.log_func, absreal("."), "", axs, {}) elif "" not in mount: # there's volumes but no root; make root inaccessible - vfs = VFS(self.log_func, "", "", AXS(), {}) - vfs.flags["tcolor"] = self.args.tcolor - vfs.flags["d2d"] = True + zsd = {"d2d": True, "tcolor": self.args.tcolor} + vfs = VFS(self.log_func, "", "", AXS(), zsd) maxdepth = 0 for dst in sorted(mount.keys(), key=lambda x: (x.count("/"), len(x))): @@ -1490,6 +1501,52 @@ class AuthSrv(object): vol.all_vps.sort(key=lambda x: len(x[0]), reverse=True) vol.root = vfs + enshare = self.args.shr + shr = enshare[1:-1] + shrs = enshare[1:] + if enshare: + import sqlite3 + + shv = VFS(self.log_func, "", shr, AXS(), {"d2d": True}) + par = vfs.all_vols[""] + + db_path = self.args.shr_db + db = sqlite3.connect(db_path) + cur = db.cursor() + now = time.time() + for row in cur.execute("select * from sh"): + s_k, s_pw, s_vp, s_pr, s_st, s_un, s_t0, s_t1 = row + if s_t1 and s_t1 < now: + continue + + if self.args.shr_v: + t = "loading %s share [%s] by [%s] => [%s]" + self.log(t % (s_pr, s_k, s_un, s_vp)) + + if s_pw: + sun = "s_%s" % (s_k,) + acct[sun] = s_pw + else: + sun = "*" + + s_axs = AXS( + [sun] if "r" in s_pr else [], + [sun] if "w" in s_pr else [], + [sun] if "m" in s_pr else [], + [sun] if "d" in s_pr else [], + ) + + # don't know the abspath yet + wanna ensure the user + # still has the privs they granted, so nullmap it + shv.nodes[s_k] = VFS( + self.log_func, "", "%s/%s" % (shr, s_k), s_axs, par.flags.copy() + ) + + vfs.nodes[shr] = vfs.all_vols[shr] = shv + for vol in shv.nodes.values(): + vfs.all_vols[vol.vpath] = vol + vol.get_dbv = vol._get_share_src + zss = set(acct) zss.update(self.idp_accs) zss.discard("*") @@ -1508,7 +1565,7 @@ class AuthSrv(object): for usr in unames: for vp, vol in vfs.all_vols.items(): zx = getattr(vol.axs, axs_key) - if usr in zx: + if usr in zx and (not enshare or not vp.startswith(shrs)): umap[usr].append(vp) umap[usr].sort() setattr(vfs, "a" + perm, umap) @@ -1558,6 +1615,8 @@ class AuthSrv(object): for usr in acct: if usr not in associated_users: + if enshare and usr.startswith("s_"): + continue if len(vfs.all_vols) > 1: # user probably familiar enough that the verbose message is not necessary t = "account [%s] is not mentioned in any volume definitions; see --help-accounts" @@ -1993,7 +2052,7 @@ class AuthSrv(object): have_e2t = False t = "volumes and permissions:\n" for zv in vfs.all_vols.values(): - if not self.warn_anonwrite: + if not self.warn_anonwrite or verbosity < 5: break t += '\n\033[36m"/{}" \033[33m{}\033[0m'.format(zv.vpath, zv.realpath) @@ -2022,7 +2081,7 @@ class AuthSrv(object): t += "\n" - if self.warn_anonwrite: + if self.warn_anonwrite and verbosity > 4: if not self.args.no_voldump: self.log(t) @@ -2046,7 +2105,7 @@ class AuthSrv(object): try: zv, _ = vfs.get("", "*", False, True, err=999) - if self.warn_anonwrite and os.getcwd() == zv.realpath: + if self.warn_anonwrite and verbosity > 4 and os.getcwd() == zv.realpath: t = "anyone can write to the current directory: {}\n" self.log(t.format(zv.realpath), c=1) @@ -2094,6 +2153,51 @@ class AuthSrv(object): MIMES[ext] = mime EXTS.update({v: k for k, v in MIMES.items()}) + if enshare: + # hide shares from controlpanel + vfs.all_vols = { + x: y + for x, y in vfs.all_vols.items() + if x != shr and not x.startswith(shrs) + } + + assert cur # type: ignore + assert shv # type: ignore + for row in cur.execute("select * from sh"): + s_k, s_pw, s_vp, s_pr, s_st, s_un, s_t0, s_t1 = row + shn = shv.nodes.get(s_k, None) + if not shn: + continue + + try: + s_vfs, s_rem = vfs.get( + s_vp, s_un, "r" in s_pr, "w" in s_pr, "m" in s_pr, "d" in s_pr + ) + except Exception as ex: + t = "removing share [%s] by [%s] to [%s] due to %r" + self.log(t % (s_k, s_un, s_vp, ex), 3) + shv.nodes.pop(s_k) + continue + + shn.shr_src = (s_vfs, s_rem) + shn.realpath = s_vfs.canonical(s_rem) + + if self.args.shr_v: + t = "mapped %s share [%s] by [%s] => [%s] => [%s]" + self.log(t % (s_pr, s_k, s_un, s_vp, shn.realpath)) + + # transplant shadowing into shares + for vn in shv.nodes.values(): + svn, srem = vn.shr_src # type: ignore + if srem: + continue # free branch, safe + ap = svn.canonical(srem) + if bos.path.isfile(ap): + continue # also fine + for zs in svn.nodes.keys(): + # hide subvolume + vn.nodes[zs] = VFS(self.log_func, "", "", AXS(), {}) + def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]: if not self.args.chpw: return False, "feature disabled in server config" diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index dd153ff9..34713287 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -45,6 +45,7 @@ from .util import unquote # type: ignore from .util import ( APPLESAN_RE, BITNESS, + HAVE_SQLITE3, HTTPCODE, META_NOBOTS, UTC, @@ -454,7 +455,7 @@ class HttpCli(object): t = "incorrect --rp-loc or webserver config; expected vpath starting with [{}] but got [{}]" self.log(t.format(self.args.R, vpath), 1) - self.ouparam = {k: zs for k, zs in uparam.items()} + self.ouparam = uparam.copy() if self.args.rsp_slp: time.sleep(self.args.rsp_slp) @@ -971,7 +972,7 @@ class HttpCli(object): vp = self.args.SRS + vpath html = self.j2s( "msg", - h2='%s %s' % ( + h2='{} {}'.format( quotep(vp) + suf, flavor, html_escape(vp, crlf=True) + suf ), pre=msg, @@ -1141,7 +1142,7 @@ class HttpCli(object): if "move" in self.uparam: return self.handle_mv() - if not self.vpath: + if not self.vpath and self.ouparam: if "reload" in self.uparam: return self.handle_reload() @@ -1163,23 +1164,12 @@ class HttpCli(object): if "hc" in self.uparam: return self.tx_svcs() + if "shares" in self.uparam: + return self.tx_shares() + if "h" in self.uparam: return self.tx_mounts() - # conditional redirect to single volumes - if not self.vpath and not self.ouparam: - nread = len(self.rvol) - nwrite = len(self.wvol) - if nread + nwrite == 1 or (self.rvol == self.wvol and nread == 1): - if nread == 1: - vpath = self.rvol[0] - else: - vpath = self.wvol[0] - - if self.vpath != vpath: - self.redirect(vpath, flavor="redirecting to", use302=True) - return True - return self.tx_browser() def handle_propfind(self) -> bool: @@ -1618,6 +1608,9 @@ class HttpCli(object): if "delete" in self.uparam: return self.handle_rm([]) + if "unshare" in self.uparam: + return self.handle_unshare() + if "application/octet-stream" in ctype: return self.handle_post_binary() @@ -2150,6 +2143,9 @@ class HttpCli(object): if "srch" in self.uparam or "srch" in body: return self.handle_search(body) + if "share" in self.uparam: + return self.handle_share(body) + if "delete" in self.uparam: return self.handle_rm(body) @@ -2206,7 +2202,9 @@ class HttpCli(object): def handle_search(self, body: dict[str, Any]) -> bool: idx = self.conn.get_u2idx() if not idx or not hasattr(idx, "p_end"): - raise Pebkac(500, "server busy, or sqlite3 not available; cannot search") + if not HAVE_SQLITE3: + raise Pebkac(500, "sqlite3 not found on server; search is disabled") + raise Pebkac(500, "server busy, cannot search; please retry in a bit") vols: list[VFS] = [] seen: dict[VFS, bool] = {} @@ -4179,7 +4177,9 @@ class HttpCli(object): def tx_ups(self) -> bool: idx = self.conn.get_u2idx() if not idx or not hasattr(idx, "p_end"): - raise Pebkac(500, "sqlite3 is not available on the server; cannot unpost") + if not HAVE_SQLITE3: + raise Pebkac(500, "sqlite3 not found on server; unpost is disabled") + raise Pebkac(500, "server busy, cannot unpost; please retry in a bit") filt = self.uparam.get("filter") or "" lm = "ups [{}]".format(filt) @@ -4268,6 +4268,137 @@ class HttpCli(object): self.reply(jtxt.encode("utf-8", "replace"), mime="application/json") return True + def tx_shares(self) -> bool: + if self.uname == "*": + self.loud_reply("you're not logged in") + return True + + idx = self.conn.get_u2idx() + if not idx or not hasattr(idx, "p_end"): + if not HAVE_SQLITE3: + raise Pebkac(500, "sqlite3 not found on server; sharing is disabled") + raise Pebkac(500, "server busy, cannot list shares; please retry in a bit") + + cur = idx.get_shr() + if not cur: + raise Pebkac(400, "huh, sharing must be disabled in the server config...") + + rows = cur.execute("select * from sh").fetchall() + rows = [list(x) for x in rows] + + if self.uname != self.args.shr_adm: + rows = [x for x in rows if x[5] == self.uname] + + for x in rows: + x[1] = "yes" if x[1] else "" + + html = self.j2s( + "shares", this=self, shr=self.args.shr, rows=rows, now=int(time.time()) + ) + self.reply(html.encode("utf-8"), status=200) + return True + + def handle_unshare(self) -> bool: + idx = self.conn.get_u2idx() + if not idx or not hasattr(idx, "p_end"): + if not HAVE_SQLITE3: + raise Pebkac(500, "sqlite3 not found on server; sharing is disabled") + raise Pebkac(500, "server busy, cannot create share; please retry in a bit") + + if self.args.shr_v: + self.log("handle_unshare: " + self.req) + + cur = idx.get_shr() + if not cur: + raise Pebkac(400, "huh, sharing must be disabled in the server config...") + + skey = self.vpath.split("/")[-1] + + uns = cur.execute("select un from sh where k = ?", (skey,)).fetchall() + un = uns[0][0] if uns and uns[0] else "" + + if not un: + raise Pebkac(400, "that sharekey didn't match anything") + + if un != self.uname and self.uname != self.args.shr_adm: + t = "your username (%r) does not match the sharekey's owner (%r) and you're not admin" + raise Pebkac(400, t % (self.uname, un)) + + cur.execute("delete from sh where k = ?", (skey,)) + cur.connection.commit() + + self.redirect(self.args.SRS + "?shares") + return True + + def handle_share(self, req: dict[str, str]) -> bool: + idx = self.conn.get_u2idx() + if not idx or not hasattr(idx, "p_end"): + if not HAVE_SQLITE3: + raise Pebkac(500, "sqlite3 not found on server; sharing is disabled") + raise Pebkac(500, "server busy, cannot create share; please retry in a bit") + + if self.args.shr_v: + self.log("handle_share: " + json.dumps(req, indent=4)) + + skey = req["k"] + vp = req["vp"].strip("/") + if self.is_vproxied and (vp == self.args.R or vp.startswith(self.args.RS)): + vp = vp[len(self.args.RS) :] + + m = re.search(r"([^0-9a-zA-Z_\.-]|\.\.|^\.)", skey) + if m: + raise Pebkac(400, "sharekey has illegal character [%s]" % (m[1],)) + + if vp.startswith(self.args.shr[1:]): + raise Pebkac(400, "yo dawg...") + + cur = idx.get_shr() + if not cur: + raise Pebkac(400, "huh, sharing must be disabled in the server config...") + + q = "select * from sh where k = ?" + qr = cur.execute(q, (skey,)).fetchall() + if qr and qr[0]: + self.log("sharekey taken by %r" % (qr,)) + raise Pebkac(400, "sharekey [%s] is already in use" % (skey,)) + + # ensure user has requested perms + s_rd = "read" in req["perms"] + s_wr = "write" in req["perms"] + s_mv = "move" in req["perms"] + s_del = "delete" in req["perms"] + try: + vfs, rem = self.asrv.vfs.get(vp, self.uname, s_rd, s_wr, s_mv, s_del) + except: + raise Pebkac(400, "you dont have all the perms you tried to grant") + + ap = vfs.canonical(rem) + st = bos.stat(ap) + ist = 2 if stat.S_ISDIR(st.st_mode) else 1 + + pw = req.get("pw") or "" + now = int(time.time()) + sexp = req["exp"] + exp = now + int(sexp) * 60 if sexp else 0 + pr = "".join(zc for zc, zb in zip("rwmd", (s_rd, s_wr, s_mv, s_del)) if zb) + + q = "insert into sh values (?,?,?,?,?,?,?,?)" + cur.execute(q, (skey, pw, vp, pr, ist, self.uname, now, exp)) + cur.connection.commit() + + self.conn.hsrv.broker.ask("_reload_blocking", False, False).get() + self.conn.hsrv.broker.ask("up2k.wake_rescanner").get() + + surl = "%s://%s%s%s%s" % ( + "https" if self.is_https else "http", + self.host, + self.args.SR, + self.args.shr, + skey, + ) + self.loud_reply(surl, status=201) + return True + def handle_rm(self, req: list[str]) -> bool: if not req and not self.can_delete: raise Pebkac(403, "not allowed for user " + self.uname) @@ -4666,6 +4797,7 @@ class HttpCli(object): "have_mv": (not self.args.no_mv), "have_del": (not self.args.no_del), "have_zip": (not self.args.no_zip), + "have_shr": self.args.shr, "have_unpost": int(self.args.unpost), "sb_md": "" if "no_sb_md" in vf else (vf.get("md_sbf") or "y"), "dgrid": "grid" in vf, diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index d4ecbd2d..ae49da34 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -154,7 +154,17 @@ class HttpSrv(object): env = jinja2.Environment() env.loader = jinja2.FileSystemLoader(os.path.join(self.E.mod, "web")) - jn = ["splash", "svcs", "browser", "browser2", "msg", "md", "mde", "cf"] + jn = [ + "splash", + "shares", + "svcs", + "browser", + "browser2", + "msg", + "md", + "mde", + "cf", + ] self.j2 = {x: env.get_template(x + ".html") for x in jn} zs = os.path.join(self.E.mod, "web", "deps", "prism.js.gz") self.prism = os.path.exists(zs) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 023dbd12..b67359e3 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -219,6 +219,9 @@ class SvcHub(object): noch.update([x for x in zsl if x]) args.chpw_no = noch + if args.shr: + self.setup_share_db() + bri = "zy"[args.theme % 2 :][:1] ch = "abcdefghijklmnopqrstuvwx"[int(args.theme / 2)] args.theme = "{0}{1} {0} {1}".format(ch, bri) @@ -364,6 +367,61 @@ class SvcHub(object): self.broker = Broker(self) + def setup_share_db(self) -> None: + al = self.args + if not HAVE_SQLITE3: + self.log("root", "sqlite3 not available; disabling --shr", 1) + al.shr = "" + return + + import sqlite3 + + al.shr = "/%s/" % (al.shr.strip("/")) + + create = True + db_path = self.args.shr_db + self.log("root", "initializing shares-db %s" % (db_path,)) + for n in range(2): + try: + db = sqlite3.connect(db_path) + cur = db.cursor() + try: + cur.execute("select count(*) from sh").fetchone() + create = False + break + except: + pass + except Exception as ex: + if n: + raise + t = "shares-db corrupt; deleting and recreating: %r" + self.log("root", t % (ex,), 3) + try: + cur.close() # type: ignore + except: + pass + try: + db.close() # type: ignore + except: + pass + os.unlink(db_path) + + assert db # type: ignore + assert cur # type: ignore + if create: + for cmd in [ + # sharekey, password, src, perms, type, owner, created, expires + r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int)", + r"create table kv (k text, v int)", + r"insert into kv values ('sver', {})".format(1), + ]: + cur.execute(cmd) + db.commit() + self.log("root", "created new shares-db") + + cur.close() + db.close() + def start_ftpd(self) -> None: time.sleep(30) @@ -832,7 +890,7 @@ class SvcHub(object): return self.reloading = 2 self.log("root", "reloading config") - self.asrv.reload() + self.asrv.reload(9 if up2k else 4) if up2k: self.up2k.reload(rescan_all_vols) else: diff --git a/copyparty/u2idx.py b/copyparty/u2idx.py index 24ad7e38..8149c248 100644 --- a/copyparty/u2idx.py +++ b/copyparty/u2idx.py @@ -59,6 +59,8 @@ class U2idx(object): self.mem_cur = sqlite3.connect(":memory:", check_same_thread=False).cursor() self.mem_cur.execute(r"create table a (b text)") + self.sh_cur: Optional["sqlite3.Cursor"] = None + self.p_end = 0.0 self.p_dur = 0.0 @@ -95,17 +97,31 @@ class U2idx(object): except: raise Pebkac(500, min_ex()) - def get_cur(self, vn: VFS) -> Optional["sqlite3.Cursor"]: - if not HAVE_SQLITE3: + def get_shr(self) -> Optional["sqlite3.Cursor"]: + if self.sh_cur: + return self.sh_cur + + if not HAVE_SQLITE3 or not self.args.shr: return None + assert sqlite3 # type: ignore + + db = sqlite3.connect(self.args.shr_db, timeout=2, check_same_thread=False) + cur = db.cursor() + cur.execute('pragma table_info("sh")').fetchall() + self.sh_cur = cur + return cur + + def get_cur(self, vn: VFS) -> Optional["sqlite3.Cursor"]: cur = self.cur.get(vn.realpath) if cur: return cur - if "e2d" not in vn.flags: + if not HAVE_SQLITE3 or "e2d" not in vn.flags: return None + assert sqlite3 # type: ignore + ptop = vn.realpath histpath = self.asrv.vfs.histtab.get(ptop) if not histpath: diff --git a/copyparty/up2k.py b/copyparty/up2k.py index e44bfbf1..38c86494 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -454,11 +454,16 @@ class Up2k(object): cooldown = now + 3 # self.log("SR", 5) - if self.args.no_lifetime: + if self.args.no_lifetime and not self.args.shr: timeout = now + 9001 else: # important; not deferred by db_act timeout = self._check_lifetimes() + try: + timeout = min(self._check_shares(), timeout) + except Exception as ex: + t = "could not check for expiring shares: %r" + self.log(t % (ex,), 1) try: timeout = min(timeout, now + self._check_xiu()) @@ -561,6 +566,34 @@ class Up2k(object): return timeout + def _check_shares(self) -> float: + assert sqlite3 # type: ignore + + now = time.time() + timeout = now + 9001 + + db = sqlite3.connect(self.args.shr_db, timeout=2) + cur = db.cursor() + + q = "select k from sh where t1 and t1 <= ?" + rm = [x[0] for x in cur.execute(q, (now,))] + if rm: + self.log("forgetting expired shares %s" % (rm,)) + q = "delete from sh where k=?" + cur.executemany(q, [(x,) for x in rm]) + db.commit() + Daemon(self.hub._reload_blocking, "sharedrop", (False, False)) + + q = "select min(t1) from sh where t1 > 1" + (earliest,) = cur.execute(q).fetchone() + if earliest: + timeout = earliest - now + + cur.close() + db.close() + + return timeout + def _check_xiu(self) -> float: if self.xiu_busy: return 2 @@ -2535,6 +2568,10 @@ class Up2k(object): cur.connection.commit() + def wake_rescanner(self): + with self.rescan_cond: + self.rescan_cond.notify_all() + def handle_json( self, cj: dict[str, Any], busy_aps: dict[str, int] ) -> dict[str, Any]: diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css index 6349dd18..2e680f71 100644 --- a/copyparty/web/browser.css +++ b/copyparty/web/browser.css @@ -1147,6 +1147,7 @@ html.y #widget.open { width: 100%; height: 100%; } +#fshr, #wtgrid, #wtico { position: relative; @@ -1333,6 +1334,7 @@ html.y #widget.open { #widget.cmp #wtoggle { font-size: 1.2em; } +#widget.cmp #fshr, #widget.cmp #wtgrid { display: none; } @@ -1857,6 +1859,7 @@ html.y #tree.nowrap .ntree a+a:hover { #unpost td:nth-child(4) { text-align: right; } +#shui, #rui { background: #fff; background: var(--bg); @@ -1872,13 +1875,25 @@ html.y #tree.nowrap .ntree a+a:hover { padding: 1em; z-index: 765; } +#shui div+div, #rui div+div { margin-top: 1em; } +#shui table, #rui table { width: 100%; border-collapse: collapse; } +#shui button { + margin: 0 1em 0 0; +} +#shui .btn { + font-size: 1em; +} +#shui td { + padding: .8em 0; +} +#shui td+td, #rui td+td { padding: .2em 0 .2em .5em; } @@ -1886,10 +1901,15 @@ html.y #tree.nowrap .ntree a+a:hover { font-family: 'scp', monospace, monospace; font-family: var(--font-mono), 'scp', monospace, monospace; } +#shui td+td, #rui td+td, +#shui td input[type="text"], #rui td input[type="text"] { width: 100%; } +#shui td.exs input[type="text"] { + width: 3em; +} #rn_f.m td:first-child { white-space: nowrap; } diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 8996eab9..ac01fa72 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -309,6 +309,11 @@ var Ls = { "fd_emore": "select at least one item to delete", "fc_emore": "select at least one item to cut", + "fs_sc": "share the folder you're in", + "fs_ss": "share the selected file/folder", + "fs_just1": "select one or zero things to share", + "fs_ok": "
share-URL created
\npress Enter/OK to Clipboard\npress ESC/Cancel to Close\n\n", + "frt_dec": "may fix some cases of broken filenames\">url-decode", "frt_rst": "reset modified filenames back to the original ones\">↺ reset", "frt_abrt": "abort and close this window\">❌ cancel", @@ -826,6 +831,11 @@ var Ls = { "fd_emore": "velg minst én fil som skal slettes", "fc_emore": "velg minst én fil som skal klippes ut", + "fs_sc": "del mappen du er i nå", + "fs_ss": "del den valgte filen/mappen", + "fs_just1": "velg 1 eller 0 ting å dele", + "fs_ok": "
URL opprettet
\ntrykk Enter/OK for å kopiere linken (for CTRL-V)\ntrykk ESC/Avbryt for å bare bekrefte\n\n", + "frt_dec": "kan korrigere visse ødelagte filnavn\">url-decode", "frt_rst": "nullstiller endringer (tilbake til de originale filnavnene)\">↺ reset", "frt_abrt": "avbryt og lukk dette vinduet\">❌ avbryt", @@ -1089,6 +1099,7 @@ ebi('widget').innerHTML = ( '
' + '' + '📨sharenamedel.cut 1) + return toast.err(3, L.fs_just1); + + var vp = get_evpath(); + if (sel.length) + vp = sel[0].vp; + + vp = uricom_dec(vp.split('?')[0]); + + var shui = ebi('shui'); + if (!shui) { + shui = mknod('div', 'shui'); + document.body.appendChild(shui); + } + shui.style.display = 'block'; + + var html = [ + '
', + '', + '', + '', + '', + '', + '', + 'a').click(); diff --git a/copyparty/web/shares.css b/copyparty/web/shares.css new file mode 100644 index 00000000..77ce24d6 --- /dev/null +++ b/copyparty/web/shares.css @@ -0,0 +1,79 @@ +html { + color: #333; + background: #f7f7f7; + font-family: sans-serif; + font-family: var(--font-main), sans-serif; + touch-action: manipulation; +} +#wrap { + margin: 2em auto; + padding: 0 1em 3em 1em; + line-height: 2.3em; +} +#wrap>span { + margin: 0 0 0 1em; + border-bottom: 1px solid #999; +} +li { + margin: 1em 0; +} +a { + color: #047; + background: #fff; + text-decoration: none; + white-space: nowrap; + border-bottom: 1px solid #8ab; + border-radius: .2em; + padding: .2em .6em; + margin: 0 .3em; +} +td a { + margin: 0; +} +#w { + color: #fff; + background: #940; + border-color: #b70; +} +#repl { + border: none; + background: none; + color: inherit; + padding: 0; + position: fixed; + bottom: .25em; + left: .2em; +} +table { + border-collapse: collapse; + position: relative; +} +th { + top: -1px; + position: sticky; + background: #f7f7f7; +} +td, th { + padding: .3em .6em; + text-align: left; + white-space: nowrap; +} + + + +html.z { + background: #222; + color: #ccc; +} +html.z a { + color: #fff; + background: #057; + border-color: #37a; +} +html.z th { + background: #222; +} +html.bz { + color: #bbd; + background: #11121d; +} diff --git a/copyparty/web/shares.html b/copyparty/web/shares.html new file mode 100644 index 00000000..37f17649 --- /dev/null +++ b/copyparty/web/shares.html @@ -0,0 +1,74 @@ + + + + + + {{ s_doctitle }} + + + + + +{{ html_head }} + + + +
+ refresh + controlpanel + + axs = perms (read,write,move,delet) + st 1=file 2=dir + min/hrs = time left + +
', + '', + '', + '', + '
name
source
passwd
expiry', + ' min / ', + ' hours / ', + ' days', + '
perms', + ]; + for (var a = 0; a < perms.length; a++) + if (perms[a] != 'admin') + html.push('' + perms[a] + ''); + + html.push('
+ + + + + + + + + + + + + {% for k, pw, vp, pr, st, un, t0, t1 in rows %} + + + + + + + + + + + + + + {% endfor %} +
deletesharekeypwsourceaxsstusercreatedexpiresminhrs
delete{{ k }}{{ pw }}{{ vp|e }}{{ pr }}{{ st }}{{ un|e }}{{ t0 }}{{ t1 }}{{ (t1 - now) // 60 if t1 else "never" }}{{ (t1 - now) // 3600 if t1 else "never" }}
+ {% if not rows %} + (you don't have any active shares btw) + {% endif %} + + + +{%- if js %} + +{%- endif %} + + + diff --git a/copyparty/web/shares.js b/copyparty/web/shares.js new file mode 100644 index 00000000..70139967 --- /dev/null +++ b/copyparty/web/shares.js @@ -0,0 +1,19 @@ +var t = QSA('a[k]'); +for (var a = 0; a < t.length; a++) + t[a].onclick = rm; + +function rm() { + var u = SR + shr + uricom_enc(this.getAttribute('k')) + '?unshare', + xhr = new XHR(); + + xhr.open('POST', u, true); + xhr.onload = xhr.onerror = cb; + xhr.send(); +} + +function cb() { + if (this.status !== 200) + return modal.alert('
server error
' + esc(unpre(this.responseText))); + + document.location = '?shares'; +} diff --git a/copyparty/web/splash.html b/copyparty/web/splash.html index 31f149db..112b54a7 100644 --- a/copyparty/web/splash.html +++ b/copyparty/web/splash.html @@ -76,8 +76,12 @@ {%- endif %} -

client config:

+

other stuff:

    + {%- if this.uname != '*' and this.args.shr %} +
  • edit shares
  • + {% endif %} + {% if k304 or k304vis %} {% if k304 %}
  • disable k304 (currently enabled) diff --git a/copyparty/web/splash.js b/copyparty/web/splash.js index 549f89f2..2c786086 100644 --- a/copyparty/web/splash.js +++ b/copyparty/web/splash.js @@ -9,7 +9,7 @@ var Ls = { "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", + "cc1": "brytere og sånt", "h1": "skru av k304", "i1": "skru på k304", "j1": "k304 bryter tilkoplingen for hver HTTP 304. Dette hjelper mot visse mellomtjenere som kan sette seg fast / plutselig slutter å laste sider, men det reduserer også ytelsen betydelig", @@ -28,6 +28,7 @@ var Ls = { "v2": "bruk denne serveren som en lokal harddisk$N$NADVARSEL: kommer til å vise passordet ditt!", "w1": "bytt til https", "x1": "bytt passord", + "y1": "dine delinger", "ta1": "du må skrive et nytt passord først", "ta2": "gjenta for å bekrefte nytt passord:", "ta3": "fant en skrivefeil; vennligst prøv igjen", diff --git a/copyparty/web/util.js b/copyparty/web/util.js index 2f6e7faa..beeef4f5 100644 --- a/copyparty/web/util.js +++ b/copyparty/web/util.js @@ -473,6 +473,24 @@ function crc32(str) { } +function randstr(len) { + var ret = ''; + try { + var ar = new Uint32Array(Math.floor((len + 3) / 4)); + crypto.getRandomValues(ar); + for (var a = 0; a < ar.length; a++) + ret += ('000' + ar[a].toString(36)).slice(-4); + return ret.slice(0, len); + } + catch (ex) { + console.log('using unsafe randstr because ' + ex); + while (ret.length < len) + ret += ('000' + Math.floor(Math.random() * 1679616).toString(36)).slice(-4); + return ret.slice(0, len); + } +} + + function clmod(el, cls, add) { if (!el) return false; diff --git a/docs/devnotes.md b/docs/devnotes.md index 5130f187..78bb5587 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -139,6 +139,7 @@ authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo` | GET | `?tar&w` | pregenerate webp thumbnails | | GET | `?tar&j` | pregenerate jpg thumbnails | | GET | `?tar&p` | pregenerate audio waveforms | +| GET | `?shares` | list your shared files/folders | | GET | `?ups` | show recent uploads from your IP | | GET | `?ups&filter=f` | ...where URL contains `f` | | GET | `?mime=foo` | specify return mimetype `foo` | @@ -175,6 +176,8 @@ authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo` | mPOST | `?media` | `f=FILE` | ...and return medialink (not hotlink) | | mPOST | | `act=mkdir`, `name=foo` | create directory `foo` at URL | | POST | `?delete` | | delete URL recursively | +| POST | `?unshare` | | stop sharing a file/folder | +| jPOST | `?share` | (complicated) | create temp URL for file/folder | | jPOST | `?delete` | `["/foo","/bar"]` | delete `/foo` and `/bar` recursively | | uPOST | | `msg=foo` | send message `foo` into server log | | mPOST | | `act=tput`, `body=TEXT` | overwrite markdown document at URL | diff --git a/scripts/sfx.ls b/scripts/sfx.ls index 707f6a66..5fc14001 100644 --- a/scripts/sfx.ls +++ b/scripts/sfx.ls @@ -103,6 +103,9 @@ copyparty/web/mde.html, copyparty/web/mde.js, copyparty/web/msg.css, copyparty/web/msg.html, +copyparty/web/shares.css, +copyparty/web/shares.html, +copyparty/web/shares.js, copyparty/web/splash.css, copyparty/web/splash.html, copyparty/web/splash.js, diff --git a/tests/util.py b/tests/util.py index 04d036d7..cf67ed86 100644 --- a/tests/util.py +++ b/tests/util.py @@ -137,7 +137,7 @@ class Cfg(Namespace): ex = "db_act 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 chpw_db doctitle df exit favico idp_h_usr html_head lg_sbf log_fk md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i tcolor textfiles unlist vname R RS SR" + ex = "ah_alg bname chpw_db doctitle df exit favico idp_h_usr html_head lg_sbf log_fk md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i shr tcolor textfiles unlist vname R RS SR" ka.update(**{k: "" for k in ex.split()}) ex = "grp on403 on404 xad xar xau xban xbd xbr xbu xiu xm" @@ -233,7 +233,7 @@ class VHttpSrv(object): self.nreq = 0 self.nsus = 0 - aliases = ["splash", "browser", "browser2", "msg", "md", "mde"] + aliases = ["splash", "shares", "browser", "browser2", "msg", "md", "mde"] self.j2 = {x: J2_FILES for x in aliases} self.gpwd = Garda("")