diff --git a/README.md b/README.md index 31f5172d..d1508273 100644 --- a/README.md +++ b/README.md @@ -750,7 +750,9 @@ you can move files across browser tabs (cut in one tab, paste in another) 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 +when enabled in the server settings (`--shr`), click the bottom-right `share` button to share the folder you're currently in, or alternatively: +* select a folder first to share that folder instead +* select one or more files to share only those files 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 @@ -775,6 +777,8 @@ specify `--shr /foobar` to enable this feature; a toplevel virtual folder named 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 +**security note:** using this feature does not mean that you can skip the [accounts and volumes](#accounts-and-volumes) section -- you still need to restrict access to volumes that you do not intend to share with unauthenticated users! it is not sufficient to use rules in the reverseproxy to restrict access to just the `/share` folder. + ## batch rename diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 592e7972..46e79ac9 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -35,6 +35,7 @@ from .util import ( odfusion, relchk, statdir, + ub64enc, uncyg, undot, unhumanize, @@ -344,6 +345,7 @@ class VFS(object): 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.shr_files: set[str] = set() # filenames to include from shr_src self.aread: dict[str, list[str]] = {} self.awrite: dict[str, list[str]] = {} self.amove: dict[str, list[str]] = {} @@ -369,6 +371,7 @@ class VFS(object): self.all_vps = [] self.get_dbv = self._get_dbv + self.ls = self._ls def __repr__(self) -> str: return "VFS(%s)" % ( @@ -565,7 +568,26 @@ class VFS(object): ad, fn = os.path.split(ap) return os.path.join(absreal(ad), fn) - def ls( + def _ls_nope( + self, *a, **ka + ) -> tuple[str, list[tuple[str, os.stat_result]], dict[str, "VFS"]]: + raise Pebkac(500, "nope.avi") + + def _ls_shr( + self, + rem: str, + uname: str, + scandir: bool, + permsets: list[list[bool]], + lstat: bool = False, + ) -> tuple[str, list[tuple[str, os.stat_result]], dict[str, "VFS"]]: + """replaces _ls for certain shares (single-file, or file selection)""" + vn, rem = self.shr_src # type: ignore + abspath, real, _ = vn.ls(rem, "\n", scandir, permsets, lstat) + real = [x for x in real if os.path.basename(x[0]) in self.shr_files] + return abspath, real, {} + + def _ls( self, rem: str, uname: str, @@ -1512,9 +1534,10 @@ class AuthSrv(object): db_path = self.args.shr_db db = sqlite3.connect(db_path) cur = db.cursor() + cur2 = 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 + s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1 = row if s_t1 and s_t1 < now: continue @@ -1523,7 +1546,10 @@ class AuthSrv(object): self.log(t % (s_pr, s_k, s_un, s_vp)) if s_pw: - sun = "s_%s" % (s_k,) + # gotta reuse the "account" for all shares with this pw, + # so do a light scramble as this appears in the web-ui + zs = ub64enc(hashlib.sha512(s_pw.encode("utf-8")).digest())[4:16] + sun = "s_%s" % (zs.decode("utf-8"),) acct[sun] = s_pw else: sun = "*" @@ -1545,6 +1571,7 @@ class AuthSrv(object): for vol in shv.nodes.values(): vfs.all_vols[vol.vpath] = vol vol.get_dbv = vol._get_share_src + vol.ls = vol._ls_nope zss = set(acct) zss.update(self.idp_accs) @@ -2054,6 +2081,9 @@ class AuthSrv(object): if not self.warn_anonwrite or verbosity < 5: break + if enshare and (zv.vpath == shr or zv.vpath.startswith(shrs)): + continue + t += '\n\033[36m"/{}" \033[33m{}\033[0m'.format(zv.vpath, zv.realpath) for txt, attr in [ [" read", "uread"], @@ -2160,10 +2190,9 @@ class AuthSrv(object): if x != shr and not x.startswith(shrs) } - assert cur # type: ignore - assert shv # type: ignore + assert db and cur and cur2 and 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 + s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1 = row shn = shv.nodes.get(s_k, None) if not shn: continue @@ -2178,6 +2207,17 @@ class AuthSrv(object): shv.nodes.pop(s_k) continue + fns = [] + if s_nf: + q = "select vp from sf where k = ?" + for (s_fn,) in cur2.execute(q, (s_k,)): + fns.append(s_fn) + + shn.shr_files = set(fns) + shn.ls = shn._ls_shr + else: + shn.ls = shn._ls + shn.shr_src = (s_vfs, s_rem) shn.realpath = s_vfs.canonical(s_rem) @@ -2197,6 +2237,10 @@ class AuthSrv(object): # hide subvolume vn.nodes[zs] = VFS(self.log_func, "", "", AXS(), {}) + cur2.close() + cur.close() + db.close() + 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 b6fd11d3..91ca6bca 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -4347,11 +4347,31 @@ class HttpCli(object): self.log("handle_share: " + json.dumps(req, indent=4)) skey = req["k"] - vp = req["vp"].strip("/") + vps = req["vp"] + fns = [] + if len(vps) == 1: + vp = vps[0] + if not vp.endswith("/"): + vp, zs = vp.rsplit("/", 1) + fns = [zs] + else: + for zs in vps: + if zs.endswith("/"): + t = "you cannot select more than one folder, or mix flies and folders in one selection" + raise Pebkac(400, t) + vp = vps[0].rsplit("/", 1)[0] + for zs in vps: + vp2, fn = zs.rsplit("/", 1) + fns.append(fn) + if vp != vp2: + t = "mismatching base paths in selection:\n [%s]\n [%s]" + raise Pebkac(400, t % (vp, vp2)) + + vp = 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) + m = re.search(r"([^0-9a-zA-Z_-])", skey) if m: raise Pebkac(400, "sharekey has illegal character [%s]" % (m[1],)) @@ -4378,9 +4398,13 @@ class HttpCli(object): 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 + ap, reals, _ = vfs.ls( + rem, self.uname, not self.args.no_scandir, [[s_rd, s_wr, s_mv, s_del]] + ) + rfns = set([x[0] for x in reals]) + for fn in fns: + if fn not in rfns: + raise Pebkac(400, "selected file not found on disk: [%s]" % (fn,)) pw = req.get("pw") or "" now = int(time.time()) @@ -4390,18 +4414,25 @@ class HttpCli(object): 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() + cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp)) + q = "insert into sf values (?,?)" + for fn in fns: + cur.execute(q, (skey, fn)) + + 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" % ( + fn = quotep(fns[0]) if len(fns) == 1 else "" + + surl = "created share: %s://%s%s%s%s/%s" % ( "https" if self.is_https else "http", self.host, self.args.SR, self.args.shr, skey, + fn, ) self.loud_reply(surl, status=201) return True diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 3d18eacf..fd679332 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -377,7 +377,7 @@ class SvcHub(object): import sqlite3 al.shr = al.shr.strip("/") - if "/" in al.shr: + if "/" in al.shr or not al.shr: t = "config error: --shr must be the name of a virtual toplevel directory to put shares inside" self.log("root", t, 1) raise Exception(t) @@ -385,8 +385,9 @@ class SvcHub(object): al.shr = "/%s/" % (al.shr,) create = True + modified = False db_path = self.args.shr_db - self.log("root", "initializing shares-db %s" % (db_path,)) + self.log("root", "opening shares-db %s" % (db_path,)) for n in range(2): try: db = sqlite3.connect(db_path) @@ -412,18 +413,43 @@ class SvcHub(object): pass os.unlink(db_path) + sch1 = [ + r"create table kv (k text, v int)", + r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int)", + # sharekey, password, src, perms, numFiles, owner, created, expires + ] + sch2 = [ + r"create table sf (k text, vp text)", + r"create index sf_k on sf(k)", + r"create index sh_k on sh(k)", + r"create index sh_t1 on sh(t1)", + ] + assert db # type: ignore assert cur # type: ignore if create: + dver = 2 + modified = True + for cmd in sch1 + sch2: + cur.execute(cmd) + self.log("root", "created new shares-db") + else: + (dver,) = cur.execute("select v from kv where k = 'sver'").fetchall()[0] + + if dver == 1: + modified = True + for cmd in sch2: + cur.execute(cmd) + cur.execute("update sh set st = 0") + self.log("root", "shares-db schema upgrade ok") + + if modified: 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), + r"delete from kv where k = 'sver'", + r"insert into kv values ('sver', %d)" % (2,), ]: cur.execute(cmd) db.commit() - self.log("root", "created new shares-db") cur.close() db.close() diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 7213d01a..c4821171 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -580,8 +580,8 @@ class Up2k(object): 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]) + cur.executemany("delete from sh where k=?", [(x,) for x in rm]) + cur.executemany("delete from sf where k=?", [(x,) for x in rm]) db.commit() Daemon(self.hub._reload_blocking, "sharedrop", (False, False)) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 09ed41c3..3c6fa79a 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -310,8 +310,8 @@ var Ls = { "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_ss": "share the selected files", + "fs_just1d": "you cannot select more than one folder,\nor mix flies and folders in one selection", "fs_abrt": "❌ abort", "fs_rand": "🎲 rand.name", "fs_go": "✅ create share", @@ -846,8 +846,8 @@ var Ls = { "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_ss": "del de valgte filene", + "fs_just1d": "du kan ikke markere flere mapper samtidig,\neller kombinere mapper og filer", "fs_abrt": "❌ avbryt", "fs_rand": "🎲 tilfeldig navn", "fs_go": "✅ opprett deling", @@ -1382,8 +1382,8 @@ var Ls = { "fc_emore": "选择至少一个项目以剪切", "fs_sc": "分享你所在的文件夹", - "fs_ss": "分享选定的文件/文件夹", - "fs_just1": "选择一个或零个项目进行分享", + "fs_ss": "分享选定的文件", + "fs_just1d": "你不能同时选择多个文件夹,也不能同时选择文件夹和文件", "fs_abrt": "❌ 取消", "fs_rand": "🎲 随机名称", "fs_go": "✅ 创建分享", @@ -4286,7 +4286,6 @@ var fileman = (function () { endel = nsel, encut = nsel, enpst = r.clip && r.clip.length, - enshr = nsel < 2, hren = !(have_mv && has(perms, 'write') && has(perms, 'move')), hdel = !(have_del && has(perms, 'delete')), hcut = !(have_mv && has(perms, 'move')), @@ -4300,7 +4299,7 @@ var fileman = (function () { clmod(bdel, 'en', endel); clmod(bcut, 'en', encut); clmod(bpst, 'en', enpst); - clmod(bshr, 'en', enshr); + clmod(bshr, 'en', 1); clmod(bren, 'hide', hren); clmod(bdel, 'hide', hdel); @@ -4359,15 +4358,19 @@ var fileman = (function () { r.share = function (e) { ev(e); - var sel = msel.getsel(); - if (sel.length > 1) - return toast.err(3, L.fs_just1); + var vp = uricom_dec(get_evpath()), + sel = msel.getsel(), + fns = []; - var vp = get_evpath(); - if (sel.length) - vp = sel[0].vp; + for (var a = 0; a < sel.length; a++) + fns.push(uricom_dec(noq_href(ebi(sel[a].id)))); - vp = uricom_dec(vp.split('?')[0]); + if (fns.length == 1 && fns[0].endsWith('/')) + vp = fns.pop(); + + for (var a = 0; a < fns.length; a++) + if (fns[a].endsWith('/')) + return toast.err(10, L.fs_just1d); var shui = ebi('shui'); if (!shui) { @@ -4384,9 +4387,9 @@ var fileman = (function () { '', '', '', - '' + L.fs_name + '', + '' + L.fs_name + '', '' + L.fs_src + '', - '' + L.fs_pwd + '', + '' + L.fs_pwd + '', '' + L.fs_exp + '', ' ' + L.fs_tmin + ' / ', ' ' + L.fs_thrs + ' / ', @@ -4452,10 +4455,7 @@ var fileman = (function () { shui.parentNode.removeChild(shui); }; sh_rand.onclick = function () { - var v = randstr(12).replace(/l/g, 'n'); - if (sel.length && !noq_href(ebi(sel[0].id)).endsWith('/')) - v += '.' + vp.split('.').pop(); - sh_k.value = v; + sh_k.value = randstr(12).replace(/l/g, 'n'); }; tt.att(shui); @@ -4468,11 +4468,17 @@ var fileman = (function () { } clmod(pbtns[0], 'on', 1); - sh_vp.value = vp; + var vpt = vp; + if (fns.length) { + vpt = fns.length + ' files in ' + vp + ' ' + for (var a = 0; a < fns.length; a++) + vpt += '「' + fns[a].split('/').pop() + '」'; + } + sh_vp.value = vpt; sh_k.oninput = function (e) { var v = this.value, - v2 = v.replace(/[^0-9a-zA-Z\.-]/g, '_'); + v2 = v.replace(/[^0-9a-zA-Z-]/g, '_'); if (v != v2) this.value = v2; @@ -4480,13 +4486,14 @@ var fileman = (function () { function shr_cb() { toast.hide(); - if (this.status !== 201) { + var surl = this.responseText; + if (this.status !== 201 || !/^created share:/.exec(surl)) { shui.style.display = 'block'; - var msg = unpre(this.responseText); + var msg = unpre(surl); toast.err(9, msg); return; } - var surl = this.responseText; + surl = surl.slice(15); modal.confirm(L.fs_ok + esc(surl), function() { cliptxt(surl, function () { toast.ok(2, 'copied to clipboard'); @@ -4508,7 +4515,7 @@ var fileman = (function () { var body = { "k": sh_k.value, - "vp": sh_vp.value, + "vp": fns.length ? fns : [sh_vp.value], "pw": sh_pw.value, "exp": exm.value, "perms": plist, @@ -4519,6 +4526,8 @@ var fileman = (function () { xhr.onload = xhr.onerror = shr_cb; xhr.send(JSON.stringify(body)); }; + + setTimeout(sh_pw.focus.bind(sh_pw), 1); }; r.rename = function (e) { diff --git a/copyparty/web/shares.html b/copyparty/web/shares.html index 028f99c6..58b109f2 100644 --- a/copyparty/web/shares.html +++ b/copyparty/web/shares.html @@ -18,7 +18,7 @@ control-panel axs = perms (read,write,move,delet) - st 1=file 2=dir + nf = numFiles (0=dir) min/hrs = time left @@ -27,7 +27,7 @@ - + diff --git a/copyparty/web/shares.js b/copyparty/web/shares.js index 90b02b50..eb2352a8 100644 --- a/copyparty/web/shares.js +++ b/copyparty/web/shares.js @@ -5,7 +5,7 @@ for (var a = 0; a < t.length; a++) 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(); @@ -14,7 +14,7 @@ function rm() { function cb() { if (this.status !== 200) return modal.alert('
server error
' + esc(unpre(this.responseText))); - + document.location = '?shares'; }
pw source axsstnf user created expires