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 = (
'