share multiple files (#84);

if files (one or more) are selected for sharing, then
a virtual folder is created to hold the selected files

if a single file is selected for sharing, then
the returned URL will point directly to that file

and fix some shares-related bugs:
* password coalescing
* log-spam on reload
This commit is contained in:
ed 2024-08-23 22:55:31 +00:00
parent 55a77c5e89
commit 8122ddedfe
8 changed files with 169 additions and 55 deletions

View file

@ -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 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 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 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 ## batch rename

View file

@ -35,6 +35,7 @@ from .util import (
odfusion, odfusion,
relchk, relchk,
statdir, statdir,
ub64enc,
uncyg, uncyg,
undot, undot,
unhumanize, unhumanize,
@ -344,6 +345,7 @@ class VFS(object):
self.dbv: Optional[VFS] = None # closest full/non-jump parent self.dbv: Optional[VFS] = None # closest full/non-jump parent
self.lim: Optional[Lim] = None # upload limits; only set for dbv 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_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.aread: dict[str, list[str]] = {}
self.awrite: dict[str, list[str]] = {} self.awrite: dict[str, list[str]] = {}
self.amove: dict[str, list[str]] = {} self.amove: dict[str, list[str]] = {}
@ -369,6 +371,7 @@ class VFS(object):
self.all_vps = [] self.all_vps = []
self.get_dbv = self._get_dbv self.get_dbv = self._get_dbv
self.ls = self._ls
def __repr__(self) -> str: def __repr__(self) -> str:
return "VFS(%s)" % ( return "VFS(%s)" % (
@ -565,7 +568,26 @@ class VFS(object):
ad, fn = os.path.split(ap) ad, fn = os.path.split(ap)
return os.path.join(absreal(ad), fn) 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, self,
rem: str, rem: str,
uname: str, uname: str,
@ -1512,9 +1534,10 @@ class AuthSrv(object):
db_path = self.args.shr_db db_path = self.args.shr_db
db = sqlite3.connect(db_path) db = sqlite3.connect(db_path)
cur = db.cursor() cur = db.cursor()
cur2 = db.cursor()
now = time.time() now = time.time()
for row in cur.execute("select * from sh"): 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: if s_t1 and s_t1 < now:
continue continue
@ -1523,7 +1546,10 @@ class AuthSrv(object):
self.log(t % (s_pr, s_k, s_un, s_vp)) self.log(t % (s_pr, s_k, s_un, s_vp))
if s_pw: 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 acct[sun] = s_pw
else: else:
sun = "*" sun = "*"
@ -1545,6 +1571,7 @@ class AuthSrv(object):
for vol in shv.nodes.values(): for vol in shv.nodes.values():
vfs.all_vols[vol.vpath] = vol vfs.all_vols[vol.vpath] = vol
vol.get_dbv = vol._get_share_src vol.get_dbv = vol._get_share_src
vol.ls = vol._ls_nope
zss = set(acct) zss = set(acct)
zss.update(self.idp_accs) zss.update(self.idp_accs)
@ -2054,6 +2081,9 @@ class AuthSrv(object):
if not self.warn_anonwrite or verbosity < 5: if not self.warn_anonwrite or verbosity < 5:
break 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) t += '\n\033[36m"/{}" \033[33m{}\033[0m'.format(zv.vpath, zv.realpath)
for txt, attr in [ for txt, attr in [
[" read", "uread"], [" read", "uread"],
@ -2160,10 +2190,9 @@ class AuthSrv(object):
if x != shr and not x.startswith(shrs) if x != shr and not x.startswith(shrs)
} }
assert cur # type: ignore assert db and cur and cur2 and shv # type: ignore
assert shv # type: ignore
for row in cur.execute("select * from sh"): 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) shn = shv.nodes.get(s_k, None)
if not shn: if not shn:
continue continue
@ -2178,6 +2207,17 @@ class AuthSrv(object):
shv.nodes.pop(s_k) shv.nodes.pop(s_k)
continue 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.shr_src = (s_vfs, s_rem)
shn.realpath = s_vfs.canonical(s_rem) shn.realpath = s_vfs.canonical(s_rem)
@ -2197,6 +2237,10 @@ class AuthSrv(object):
# hide subvolume # hide subvolume
vn.nodes[zs] = VFS(self.log_func, "", "", AXS(), {}) 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]: def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]:
if not self.args.chpw: if not self.args.chpw:
return False, "feature disabled in server config" return False, "feature disabled in server config"

View file

@ -4347,11 +4347,31 @@ class HttpCli(object):
self.log("handle_share: " + json.dumps(req, indent=4)) self.log("handle_share: " + json.dumps(req, indent=4))
skey = req["k"] 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)): if self.is_vproxied and (vp == self.args.R or vp.startswith(self.args.RS)):
vp = vp[len(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: if m:
raise Pebkac(400, "sharekey has illegal character [%s]" % (m[1],)) raise Pebkac(400, "sharekey has illegal character [%s]" % (m[1],))
@ -4378,9 +4398,13 @@ class HttpCli(object):
except: except:
raise Pebkac(400, "you dont have all the perms you tried to grant") raise Pebkac(400, "you dont have all the perms you tried to grant")
ap = vfs.canonical(rem) ap, reals, _ = vfs.ls(
st = bos.stat(ap) rem, self.uname, not self.args.no_scandir, [[s_rd, s_wr, s_mv, s_del]]
ist = 2 if stat.S_ISDIR(st.st_mode) else 1 )
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 "" pw = req.get("pw") or ""
now = int(time.time()) 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) pr = "".join(zc for zc, zb in zip("rwmd", (s_rd, s_wr, s_mv, s_del)) if zb)
q = "insert into sh values (?,?,?,?,?,?,?,?)" q = "insert into sh values (?,?,?,?,?,?,?,?)"
cur.execute(q, (skey, pw, vp, pr, ist, self.uname, now, exp)) cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp))
cur.connection.commit()
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("_reload_blocking", False, False).get()
self.conn.hsrv.broker.ask("up2k.wake_rescanner").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", "https" if self.is_https else "http",
self.host, self.host,
self.args.SR, self.args.SR,
self.args.shr, self.args.shr,
skey, skey,
fn,
) )
self.loud_reply(surl, status=201) self.loud_reply(surl, status=201)
return True return True

View file

@ -377,7 +377,7 @@ class SvcHub(object):
import sqlite3 import sqlite3
al.shr = al.shr.strip("/") 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" t = "config error: --shr must be the name of a virtual toplevel directory to put shares inside"
self.log("root", t, 1) self.log("root", t, 1)
raise Exception(t) raise Exception(t)
@ -385,8 +385,9 @@ class SvcHub(object):
al.shr = "/%s/" % (al.shr,) al.shr = "/%s/" % (al.shr,)
create = True create = True
modified = False
db_path = self.args.shr_db 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): for n in range(2):
try: try:
db = sqlite3.connect(db_path) db = sqlite3.connect(db_path)
@ -412,18 +413,43 @@ class SvcHub(object):
pass pass
os.unlink(db_path) 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 db # type: ignore
assert cur # type: ignore assert cur # type: ignore
if create: 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 [ for cmd in [
# sharekey, password, src, perms, type, owner, created, expires r"delete from kv where k = 'sver'",
r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int)", r"insert into kv values ('sver', %d)" % (2,),
r"create table kv (k text, v int)",
r"insert into kv values ('sver', {})".format(1),
]: ]:
cur.execute(cmd) cur.execute(cmd)
db.commit() db.commit()
self.log("root", "created new shares-db")
cur.close() cur.close()
db.close() db.close()

View file

@ -580,8 +580,8 @@ class Up2k(object):
rm = [x[0] for x in cur.execute(q, (now,))] rm = [x[0] for x in cur.execute(q, (now,))]
if rm: if rm:
self.log("forgetting expired shares %s" % (rm,)) self.log("forgetting expired shares %s" % (rm,))
q = "delete from sh where k=?" cur.executemany("delete from sh where k=?", [(x,) for x in rm])
cur.executemany(q, [(x,) for x in rm]) cur.executemany("delete from sf where k=?", [(x,) for x in rm])
db.commit() db.commit()
Daemon(self.hub._reload_blocking, "sharedrop", (False, False)) Daemon(self.hub._reload_blocking, "sharedrop", (False, False))

View file

@ -310,8 +310,8 @@ var Ls = {
"fc_emore": "select at least one item to cut", "fc_emore": "select at least one item to cut",
"fs_sc": "share the folder you're in", "fs_sc": "share the folder you're in",
"fs_ss": "share the selected file/folder", "fs_ss": "share the selected files",
"fs_just1": "select one or zero things to share", "fs_just1d": "you cannot select more than one folder,\nor mix flies and folders in one selection",
"fs_abrt": "❌ abort", "fs_abrt": "❌ abort",
"fs_rand": "🎲 rand.name", "fs_rand": "🎲 rand.name",
"fs_go": "✅ create share", "fs_go": "✅ create share",
@ -846,8 +846,8 @@ var Ls = {
"fc_emore": "velg minst én fil som skal klippes ut", "fc_emore": "velg minst én fil som skal klippes ut",
"fs_sc": "del mappen du er i nå", "fs_sc": "del mappen du er i nå",
"fs_ss": "del den valgte filen/mappen", "fs_ss": "del de valgte filene",
"fs_just1": "velg 1 eller 0 ting å dele", "fs_just1d": "du kan ikke markere flere mapper samtidig,\neller kombinere mapper og filer",
"fs_abrt": "❌ avbryt", "fs_abrt": "❌ avbryt",
"fs_rand": "🎲 tilfeldig navn", "fs_rand": "🎲 tilfeldig navn",
"fs_go": "✅ opprett deling", "fs_go": "✅ opprett deling",
@ -1382,8 +1382,8 @@ var Ls = {
"fc_emore": "选择至少一个项目以剪切", "fc_emore": "选择至少一个项目以剪切",
"fs_sc": "分享你所在的文件夹", "fs_sc": "分享你所在的文件夹",
"fs_ss": "分享选定的文件/文件夹", "fs_ss": "分享选定的文件",
"fs_just1": "选择一个或零个项目进行分享", "fs_just1d": "你不能同时选择多个文件夹,也不能同时选择文件夹和文件",
"fs_abrt": "❌ 取消", "fs_abrt": "❌ 取消",
"fs_rand": "🎲 随机名称", "fs_rand": "🎲 随机名称",
"fs_go": "✅ 创建分享", "fs_go": "✅ 创建分享",
@ -4286,7 +4286,6 @@ var fileman = (function () {
endel = nsel, endel = nsel,
encut = nsel, encut = nsel,
enpst = r.clip && r.clip.length, enpst = r.clip && r.clip.length,
enshr = nsel < 2,
hren = !(have_mv && has(perms, 'write') && has(perms, 'move')), hren = !(have_mv && has(perms, 'write') && has(perms, 'move')),
hdel = !(have_del && has(perms, 'delete')), hdel = !(have_del && has(perms, 'delete')),
hcut = !(have_mv && has(perms, 'move')), hcut = !(have_mv && has(perms, 'move')),
@ -4300,7 +4299,7 @@ var fileman = (function () {
clmod(bdel, 'en', endel); clmod(bdel, 'en', endel);
clmod(bcut, 'en', encut); clmod(bcut, 'en', encut);
clmod(bpst, 'en', enpst); clmod(bpst, 'en', enpst);
clmod(bshr, 'en', enshr); clmod(bshr, 'en', 1);
clmod(bren, 'hide', hren); clmod(bren, 'hide', hren);
clmod(bdel, 'hide', hdel); clmod(bdel, 'hide', hdel);
@ -4359,15 +4358,19 @@ var fileman = (function () {
r.share = function (e) { r.share = function (e) {
ev(e); ev(e);
var sel = msel.getsel(); var vp = uricom_dec(get_evpath()),
if (sel.length > 1) sel = msel.getsel(),
return toast.err(3, L.fs_just1); fns = [];
var vp = get_evpath(); for (var a = 0; a < sel.length; a++)
if (sel.length) fns.push(uricom_dec(noq_href(ebi(sel[a].id))));
vp = sel[0].vp;
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'); var shui = ebi('shui');
if (!shui) { if (!shui) {
@ -4452,10 +4455,7 @@ var fileman = (function () {
shui.parentNode.removeChild(shui); shui.parentNode.removeChild(shui);
}; };
sh_rand.onclick = function () { sh_rand.onclick = function () {
var v = randstr(12).replace(/l/g, 'n'); sh_k.value = randstr(12).replace(/l/g, 'n');
if (sel.length && !noq_href(ebi(sel[0].id)).endsWith('/'))
v += '.' + vp.split('.').pop();
sh_k.value = v;
}; };
tt.att(shui); tt.att(shui);
@ -4468,11 +4468,17 @@ var fileman = (function () {
} }
clmod(pbtns[0], 'on', 1); 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) { sh_k.oninput = function (e) {
var v = this.value, var v = this.value,
v2 = v.replace(/[^0-9a-zA-Z\.-]/g, '_'); v2 = v.replace(/[^0-9a-zA-Z-]/g, '_');
if (v != v2) if (v != v2)
this.value = v2; this.value = v2;
@ -4480,13 +4486,14 @@ var fileman = (function () {
function shr_cb() { function shr_cb() {
toast.hide(); toast.hide();
if (this.status !== 201) { var surl = this.responseText;
if (this.status !== 201 || !/^created share:/.exec(surl)) {
shui.style.display = 'block'; shui.style.display = 'block';
var msg = unpre(this.responseText); var msg = unpre(surl);
toast.err(9, msg); toast.err(9, msg);
return; return;
} }
var surl = this.responseText; surl = surl.slice(15);
modal.confirm(L.fs_ok + esc(surl), function() { modal.confirm(L.fs_ok + esc(surl), function() {
cliptxt(surl, function () { cliptxt(surl, function () {
toast.ok(2, 'copied to clipboard'); toast.ok(2, 'copied to clipboard');
@ -4508,7 +4515,7 @@ var fileman = (function () {
var body = { var body = {
"k": sh_k.value, "k": sh_k.value,
"vp": sh_vp.value, "vp": fns.length ? fns : [sh_vp.value],
"pw": sh_pw.value, "pw": sh_pw.value,
"exp": exm.value, "exp": exm.value,
"perms": plist, "perms": plist,
@ -4519,6 +4526,8 @@ var fileman = (function () {
xhr.onload = xhr.onerror = shr_cb; xhr.onload = xhr.onerror = shr_cb;
xhr.send(JSON.stringify(body)); xhr.send(JSON.stringify(body));
}; };
setTimeout(sh_pw.focus.bind(sh_pw), 1);
}; };
r.rename = function (e) { r.rename = function (e) {

View file

@ -18,7 +18,7 @@
<a id="a" href="{{ r }}/?h" class="af">control-panel</a> <a id="a" href="{{ r }}/?h" class="af">control-panel</a>
<span>axs = perms (read,write,move,delet)</span> <span>axs = perms (read,write,move,delet)</span>
<span>st 1=file 2=dir</span> <span>nf = numFiles (0=dir)</span>
<span>min/hrs = time left</span> <span>min/hrs = time left</span>
<table id="tab"><thead><tr> <table id="tab"><thead><tr>
@ -27,7 +27,7 @@
<th>pw</th> <th>pw</th>
<th>source</th> <th>source</th>
<th>axs</th> <th>axs</th>
<th>st</th> <th>nf</th>
<th>user</th> <th>user</th>
<th>created</th> <th>created</th>
<th>expires</th> <th>expires</th>