Skip to content

Commit 8122dde

Browse files
committed
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
1 parent 55a77c5 commit 8122dde

File tree

8 files changed

+169
-55
lines changed

8 files changed

+169
-55
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,9 @@ you can move files across browser tabs (cut in one tab, paste in another)
750750
751751
share a file or folder by creating a temporary link
752752
753-
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
753+
when enabled in the server settings (`--shr`), click the bottom-right `share` button to share the folder you're currently in, or alternatively:
754+
* select a folder first to share that folder instead
755+
* select one or more files to share only those files
754756
755757
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
756758
@@ -775,6 +777,8 @@ specify `--shr /foobar` to enable this feature; a toplevel virtual folder named
775777
776778
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
777779
780+
**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.
781+
778782
779783
## batch rename
780784

copyparty/authsrv.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
odfusion,
3636
relchk,
3737
statdir,
38+
ub64enc,
3839
uncyg,
3940
undot,
4041
unhumanize,
@@ -344,6 +345,7 @@ def __init__(
344345
self.dbv: Optional[VFS] = None # closest full/non-jump parent
345346
self.lim: Optional[Lim] = None # upload limits; only set for dbv
346347
self.shr_src: Optional[tuple[VFS, str]] = None # source vfs+rem of a share
348+
self.shr_files: set[str] = set() # filenames to include from shr_src
347349
self.aread: dict[str, list[str]] = {}
348350
self.awrite: dict[str, list[str]] = {}
349351
self.amove: dict[str, list[str]] = {}
@@ -369,6 +371,7 @@ def __init__(
369371
self.all_vps = []
370372

371373
self.get_dbv = self._get_dbv
374+
self.ls = self._ls
372375

373376
def __repr__(self) -> str:
374377
return "VFS(%s)" % (
@@ -565,7 +568,26 @@ def dcanonical(self, rem: str) -> str:
565568
ad, fn = os.path.split(ap)
566569
return os.path.join(absreal(ad), fn)
567570

568-
def ls(
571+
def _ls_nope(
572+
self, *a, **ka
573+
) -> tuple[str, list[tuple[str, os.stat_result]], dict[str, "VFS"]]:
574+
raise Pebkac(500, "nope.avi")
575+
576+
def _ls_shr(
577+
self,
578+
rem: str,
579+
uname: str,
580+
scandir: bool,
581+
permsets: list[list[bool]],
582+
lstat: bool = False,
583+
) -> tuple[str, list[tuple[str, os.stat_result]], dict[str, "VFS"]]:
584+
"""replaces _ls for certain shares (single-file, or file selection)"""
585+
vn, rem = self.shr_src # type: ignore
586+
abspath, real, _ = vn.ls(rem, "\n", scandir, permsets, lstat)
587+
real = [x for x in real if os.path.basename(x[0]) in self.shr_files]
588+
return abspath, real, {}
589+
590+
def _ls(
569591
self,
570592
rem: str,
571593
uname: str,
@@ -1512,9 +1534,10 @@ def _reload(self, verbosity: int = 9) -> None:
15121534
db_path = self.args.shr_db
15131535
db = sqlite3.connect(db_path)
15141536
cur = db.cursor()
1537+
cur2 = db.cursor()
15151538
now = time.time()
15161539
for row in cur.execute("select * from sh"):
1517-
s_k, s_pw, s_vp, s_pr, s_st, s_un, s_t0, s_t1 = row
1540+
s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1 = row
15181541
if s_t1 and s_t1 < now:
15191542
continue
15201543

@@ -1523,7 +1546,10 @@ def _reload(self, verbosity: int = 9) -> None:
15231546
self.log(t % (s_pr, s_k, s_un, s_vp))
15241547

15251548
if s_pw:
1526-
sun = "s_%s" % (s_k,)
1549+
# gotta reuse the "account" for all shares with this pw,
1550+
# so do a light scramble as this appears in the web-ui
1551+
zs = ub64enc(hashlib.sha512(s_pw.encode("utf-8")).digest())[4:16]
1552+
sun = "s_%s" % (zs.decode("utf-8"),)
15271553
acct[sun] = s_pw
15281554
else:
15291555
sun = "*"
@@ -1545,6 +1571,7 @@ def _reload(self, verbosity: int = 9) -> None:
15451571
for vol in shv.nodes.values():
15461572
vfs.all_vols[vol.vpath] = vol
15471573
vol.get_dbv = vol._get_share_src
1574+
vol.ls = vol._ls_nope
15481575

15491576
zss = set(acct)
15501577
zss.update(self.idp_accs)
@@ -2054,6 +2081,9 @@ def _reload(self, verbosity: int = 9) -> None:
20542081
if not self.warn_anonwrite or verbosity < 5:
20552082
break
20562083

2084+
if enshare and (zv.vpath == shr or zv.vpath.startswith(shrs)):
2085+
continue
2086+
20572087
t += '\n\033[36m"/{}" \033[33m{}\033[0m'.format(zv.vpath, zv.realpath)
20582088
for txt, attr in [
20592089
[" read", "uread"],
@@ -2160,10 +2190,9 @@ def _reload(self, verbosity: int = 9) -> None:
21602190
if x != shr and not x.startswith(shrs)
21612191
}
21622192

2163-
assert cur # type: ignore
2164-
assert shv # type: ignore
2193+
assert db and cur and cur2 and shv # type: ignore
21652194
for row in cur.execute("select * from sh"):
2166-
s_k, s_pw, s_vp, s_pr, s_st, s_un, s_t0, s_t1 = row
2195+
s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1 = row
21672196
shn = shv.nodes.get(s_k, None)
21682197
if not shn:
21692198
continue
@@ -2178,6 +2207,17 @@ def _reload(self, verbosity: int = 9) -> None:
21782207
shv.nodes.pop(s_k)
21792208
continue
21802209

2210+
fns = []
2211+
if s_nf:
2212+
q = "select vp from sf where k = ?"
2213+
for (s_fn,) in cur2.execute(q, (s_k,)):
2214+
fns.append(s_fn)
2215+
2216+
shn.shr_files = set(fns)
2217+
shn.ls = shn._ls_shr
2218+
else:
2219+
shn.ls = shn._ls
2220+
21812221
shn.shr_src = (s_vfs, s_rem)
21822222
shn.realpath = s_vfs.canonical(s_rem)
21832223

@@ -2197,6 +2237,10 @@ def _reload(self, verbosity: int = 9) -> None:
21972237
# hide subvolume
21982238
vn.nodes[zs] = VFS(self.log_func, "", "", AXS(), {})
21992239

2240+
cur2.close()
2241+
cur.close()
2242+
db.close()
2243+
22002244
def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]:
22012245
if not self.args.chpw:
22022246
return False, "feature disabled in server config"

copyparty/httpcli.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4347,11 +4347,31 @@ def handle_share(self, req: dict[str, str]) -> bool:
43474347
self.log("handle_share: " + json.dumps(req, indent=4))
43484348

43494349
skey = req["k"]
4350-
vp = req["vp"].strip("/")
4350+
vps = req["vp"]
4351+
fns = []
4352+
if len(vps) == 1:
4353+
vp = vps[0]
4354+
if not vp.endswith("/"):
4355+
vp, zs = vp.rsplit("/", 1)
4356+
fns = [zs]
4357+
else:
4358+
for zs in vps:
4359+
if zs.endswith("/"):
4360+
t = "you cannot select more than one folder, or mix flies and folders in one selection"
4361+
raise Pebkac(400, t)
4362+
vp = vps[0].rsplit("/", 1)[0]
4363+
for zs in vps:
4364+
vp2, fn = zs.rsplit("/", 1)
4365+
fns.append(fn)
4366+
if vp != vp2:
4367+
t = "mismatching base paths in selection:\n [%s]\n [%s]"
4368+
raise Pebkac(400, t % (vp, vp2))
4369+
4370+
vp = vp.strip("/")
43514371
if self.is_vproxied and (vp == self.args.R or vp.startswith(self.args.RS)):
43524372
vp = vp[len(self.args.RS) :]
43534373

4354-
m = re.search(r"([^0-9a-zA-Z_\.-]|\.\.|^\.)", skey)
4374+
m = re.search(r"([^0-9a-zA-Z_-])", skey)
43554375
if m:
43564376
raise Pebkac(400, "sharekey has illegal character [%s]" % (m[1],))
43574377

@@ -4378,9 +4398,13 @@ def handle_share(self, req: dict[str, str]) -> bool:
43784398
except:
43794399
raise Pebkac(400, "you dont have all the perms you tried to grant")
43804400

4381-
ap = vfs.canonical(rem)
4382-
st = bos.stat(ap)
4383-
ist = 2 if stat.S_ISDIR(st.st_mode) else 1
4401+
ap, reals, _ = vfs.ls(
4402+
rem, self.uname, not self.args.no_scandir, [[s_rd, s_wr, s_mv, s_del]]
4403+
)
4404+
rfns = set([x[0] for x in reals])
4405+
for fn in fns:
4406+
if fn not in rfns:
4407+
raise Pebkac(400, "selected file not found on disk: [%s]" % (fn,))
43844408

43854409
pw = req.get("pw") or ""
43864410
now = int(time.time())
@@ -4390,18 +4414,25 @@ def handle_share(self, req: dict[str, str]) -> bool:
43904414
pr = "".join(zc for zc, zb in zip("rwmd", (s_rd, s_wr, s_mv, s_del)) if zb)
43914415

43924416
q = "insert into sh values (?,?,?,?,?,?,?,?)"
4393-
cur.execute(q, (skey, pw, vp, pr, ist, self.uname, now, exp))
4394-
cur.connection.commit()
4417+
cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp))
4418+
4419+
q = "insert into sf values (?,?)"
4420+
for fn in fns:
4421+
cur.execute(q, (skey, fn))
43954422

4423+
cur.connection.commit()
43964424
self.conn.hsrv.broker.ask("_reload_blocking", False, False).get()
43974425
self.conn.hsrv.broker.ask("up2k.wake_rescanner").get()
43984426

4399-
surl = "%s://%s%s%s%s" % (
4427+
fn = quotep(fns[0]) if len(fns) == 1 else ""
4428+
4429+
surl = "created share: %s://%s%s%s%s/%s" % (
44004430
"https" if self.is_https else "http",
44014431
self.host,
44024432
self.args.SR,
44034433
self.args.shr,
44044434
skey,
4435+
fn,
44054436
)
44064437
self.loud_reply(surl, status=201)
44074438
return True

copyparty/svchub.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -377,16 +377,17 @@ def setup_share_db(self) -> None:
377377
import sqlite3
378378

379379
al.shr = al.shr.strip("/")
380-
if "/" in al.shr:
380+
if "/" in al.shr or not al.shr:
381381
t = "config error: --shr must be the name of a virtual toplevel directory to put shares inside"
382382
self.log("root", t, 1)
383383
raise Exception(t)
384384

385385
al.shr = "/%s/" % (al.shr,)
386386

387387
create = True
388+
modified = False
388389
db_path = self.args.shr_db
389-
self.log("root", "initializing shares-db %s" % (db_path,))
390+
self.log("root", "opening shares-db %s" % (db_path,))
390391
for n in range(2):
391392
try:
392393
db = sqlite3.connect(db_path)
@@ -412,18 +413,43 @@ def setup_share_db(self) -> None:
412413
pass
413414
os.unlink(db_path)
414415

416+
sch1 = [
417+
r"create table kv (k text, v int)",
418+
r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int)",
419+
# sharekey, password, src, perms, numFiles, owner, created, expires
420+
]
421+
sch2 = [
422+
r"create table sf (k text, vp text)",
423+
r"create index sf_k on sf(k)",
424+
r"create index sh_k on sh(k)",
425+
r"create index sh_t1 on sh(t1)",
426+
]
427+
415428
assert db # type: ignore
416429
assert cur # type: ignore
417430
if create:
431+
dver = 2
432+
modified = True
433+
for cmd in sch1 + sch2:
434+
cur.execute(cmd)
435+
self.log("root", "created new shares-db")
436+
else:
437+
(dver,) = cur.execute("select v from kv where k = 'sver'").fetchall()[0]
438+
439+
if dver == 1:
440+
modified = True
441+
for cmd in sch2:
442+
cur.execute(cmd)
443+
cur.execute("update sh set st = 0")
444+
self.log("root", "shares-db schema upgrade ok")
445+
446+
if modified:
418447
for cmd in [
419-
# sharekey, password, src, perms, type, owner, created, expires
420-
r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int)",
421-
r"create table kv (k text, v int)",
422-
r"insert into kv values ('sver', {})".format(1),
448+
r"delete from kv where k = 'sver'",
449+
r"insert into kv values ('sver', %d)" % (2,),
423450
]:
424451
cur.execute(cmd)
425452
db.commit()
426-
self.log("root", "created new shares-db")
427453

428454
cur.close()
429455
db.close()

copyparty/up2k.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,8 +580,8 @@ def _check_shares(self) -> float:
580580
rm = [x[0] for x in cur.execute(q, (now,))]
581581
if rm:
582582
self.log("forgetting expired shares %s" % (rm,))
583-
q = "delete from sh where k=?"
584-
cur.executemany(q, [(x,) for x in rm])
583+
cur.executemany("delete from sh where k=?", [(x,) for x in rm])
584+
cur.executemany("delete from sf where k=?", [(x,) for x in rm])
585585
db.commit()
586586
Daemon(self.hub._reload_blocking, "sharedrop", (False, False))
587587

0 commit comments

Comments
 (0)