diff --git a/README.md b/README.md
index 3c020c19..4fa5ff36 100644
--- a/README.md
+++ b/README.md
@@ -428,7 +428,7 @@ configuring accounts/volumes with arguments:
permissions:
* `r` (read): browse folder contents, download files, download as zip/tar, see filekeys/dirkeys
-* `w` (write): upload files, move files *into* this folder
+* `w` (write): upload files, move/copy files *into* this folder
* `m` (move): move files/folders *from* this folder
* `d` (delete): delete files/folders
* `.` (dots): user can ask to show dotfiles in directory listings
@@ -508,7 +508,8 @@ the browser has the following hotkeys (always qwerty)
* `ESC` close various things
* `ctrl-K` delete selected files/folders
* `ctrl-X` cut selected files/folders
-* `ctrl-V` paste
+* `ctrl-C` copy selected files/folders to clipboard
+* `ctrl-V` paste (move/copy)
* `Y` download selected files
* `F2` [rename](#batch-rename) selected file/folder
* when a file/folder is selected (in not-grid-view):
@@ -757,10 +758,11 @@ file selection: click somewhere on the line (not the link itself), then:
* shift-click another line for range-select
* cut: select some files and `ctrl-x`
+* copy: select some files and `ctrl-c`
* paste: `ctrl-v` in another folder
* rename: `F2`
-you can move files across browser tabs (cut in one tab, paste in another)
+you can copy/move files across browser tabs (cut/copy in one tab, paste in another)
## shares
diff --git a/bin/hooks/README.md b/bin/hooks/README.md
index f79de79e..82ef3d7e 100644
--- a/bin/hooks/README.md
+++ b/bin/hooks/README.md
@@ -2,7 +2,7 @@ standalone programs which are executed by copyparty when an event happens (uploa
these programs either take zero arguments, or a filepath (the affected file), or a json message with filepath + additional info
-run copyparty with `--help-hooks` for usage details / hook type explanations (xm/xbu/xau/xiu/xbr/xar/xbd/xad/xban)
+run copyparty with `--help-hooks` for usage details / hook type explanations (xm/xbu/xau/xiu/xbc/xac/xbr/xar/xbd/xad/xban)
> **note:** in addition to event hooks (the stuff described here), copyparty has another api to run your programs/scripts while providing way more information such as audio tags / video codecs / etc and optionally daisychaining data between scripts in a processing pipeline; if that's what you want then see [mtp plugins](../mtag/) instead
diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index 05336755..19ba327c 100644
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -684,6 +684,8 @@ def get_sects():
\033[36mxbu\033[35m executes CMD before a file upload starts
\033[36mxau\033[35m executes CMD after a file upload finishes
\033[36mxiu\033[35m executes CMD after all uploads finish and volume is idle
+ \033[36mxbc\033[35m executes CMD before a file copy
+ \033[36mxac\033[35m executes CMD after a file copy
\033[36mxbr\033[35m executes CMD before a file rename/move
\033[36mxar\033[35m executes CMD after a file rename/move
\033[36mxbd\033[35m executes CMD before a file delete
@@ -1201,6 +1203,8 @@ def add_hooks(ap):
ap2.add_argument("--xbu", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m before a file upload starts")
ap2.add_argument("--xau", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m after a file upload finishes")
ap2.add_argument("--xiu", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m after all uploads finish and volume is idle")
+ ap2.add_argument("--xbc", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m before a file copy")
+ ap2.add_argument("--xac", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m after a file copy")
ap2.add_argument("--xbr", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m before a file move/rename")
ap2.add_argument("--xar", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m after a file move/rename")
ap2.add_argument("--xbd", metavar="CMD", type=u, action="append", help="execute \033[33mCMD\033[0m before a file delete")
@@ -1233,6 +1237,7 @@ def add_optouts(ap):
ap2.add_argument("--no-dav", action="store_true", help="disable webdav support")
ap2.add_argument("--no-del", action="store_true", help="disable delete operations")
ap2.add_argument("--no-mv", action="store_true", help="disable move/rename operations")
+ ap2.add_argument("--no-cp", action="store_true", help="disable copy operations")
ap2.add_argument("-nth", action="store_true", help="no title hostname; don't show \033[33m--name\033[0m in
")
ap2.add_argument("-nih", action="store_true", help="no info hostname -- don't show in UI")
ap2.add_argument("-nid", action="store_true", help="no info disk-usage -- don't show in UI")
diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py
index 86a42206..84bd19ec 100644
--- a/copyparty/authsrv.py
+++ b/copyparty/authsrv.py
@@ -673,6 +673,10 @@ class VFS(object):
"""
recursively yields from ./rem;
rel is a unix-style user-defined vpath (not vfs-related)
+
+ NOTE: don't invoke this function from a dbv; subvols are only
+ descended into if rem is blank due to the _ls `if not rem:`
+ which intention is to prevent unintended access to subvols
"""
fsroot, vfs_ls, vfs_virt = self.ls(rem, uname, scandir, permsets, lstat=lstat)
@@ -1383,7 +1387,7 @@ class AuthSrv(object):
flags[name] = True
return
- zs = "mtp on403 on404 xbu xau xiu xbr xar xbd xad xm xban"
+ zs = "mtp on403 on404 xbu xau xiu xbc xac xbr xar xbd xad xm xban"
if name not in zs.split():
if value is True:
t = "└─add volflag [{}] = {} ({})"
@@ -1938,7 +1942,7 @@ class AuthSrv(object):
vol.flags[k] = odfusion(getattr(self.args, k), vol.flags[k])
# append additive args from argv to volflags
- hooks = "xbu xau xiu xbr xar xbd xad xm xban".split()
+ hooks = "xbu xau xiu xbc xac xbr xar xbd xad xm xban".split()
for name in "mtp on404 on403".split() + hooks:
self._read_volflag(vol.flags, name, getattr(self.args, name), True)
@@ -2641,7 +2645,7 @@ class AuthSrv(object):
]
csv = set("i p th_covers zm_on zm_off zs_on zs_off".split())
- zs = "c ihead ohead mtm mtp on403 on404 xad xar xau xiu xban xbd xbr xbu xm"
+ zs = "c ihead ohead mtm mtp on403 on404 xac xad xar xau xiu xban xbc xbd xbr xbu xm"
lst = set(zs.split())
askip = set("a v c vc cgen exp_lg exp_md theme".split())
fskip = set("exp_lg exp_md mv_re_r mv_re_t rm_re_r rm_re_t".split())
diff --git a/copyparty/cfg.py b/copyparty/cfg.py
index 3ad2d792..56d533e7 100644
--- a/copyparty/cfg.py
+++ b/copyparty/cfg.py
@@ -103,10 +103,12 @@ def vf_cmap() -> dict[str, str]:
"mte",
"mth",
"mtp",
+ "xac",
"xad",
"xar",
"xau",
"xban",
+ "xbc",
"xbd",
"xbr",
"xbu",
@@ -212,6 +214,8 @@ flagcats = {
"xbu=CMD": "execute CMD before a file upload starts",
"xau=CMD": "execute CMD after a file upload finishes",
"xiu=CMD": "execute CMD after all uploads finish and volume is idle",
+ "xbc=CMD": "execute CMD before a file copy",
+ "xac=CMD": "execute CMD after a file copy",
"xbr=CMD": "execute CMD before a file rename/move",
"xar=CMD": "execute CMD after a file rename/move",
"xbd=CMD": "execute CMD before a file delete",
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index 075dba1b..43eb3d68 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -637,7 +637,7 @@ class HttpCli(object):
avn.can_access("", self.uname) if avn else [False] * 8
)
self.avn = avn
- self.vn = vn
+ self.vn = vn # note: do not dbv due to walk/zipgen
self.rem = rem
self.s.settimeout(self.args.s_tbody or None)
@@ -1196,6 +1196,9 @@ class HttpCli(object):
if "move" in self.uparam:
return self.handle_mv()
+ if "copy" in self.uparam:
+ return self.handle_cp()
+
if not self.vpath and self.ouparam:
if "reload" in self.uparam:
return self.handle_reload()
@@ -1791,6 +1794,9 @@ class HttpCli(object):
if "move" in self.uparam:
return self.handle_mv()
+ if "copy" in self.uparam:
+ return self.handle_cp()
+
if "delete" in self.uparam:
return self.handle_rm([])
@@ -5021,16 +5027,39 @@ class HttpCli(object):
return self._mv(self.vpath, dst.lstrip("/"))
def _mv(self, vsrc: str, vdst: str) -> bool:
- if not self.can_move:
- raise Pebkac(403, "not allowed for user " + self.uname)
-
if self.args.no_mv:
raise Pebkac(403, "the rename/move feature is disabled in server config")
+ self.asrv.vfs.get(vsrc, self.uname, True, False, True)
+ self.asrv.vfs.get(vdst, self.uname, False, True)
+
x = self.conn.hsrv.broker.ask("up2k.handle_mv", self.uname, self.ip, vsrc, vdst)
self.loud_reply(x.get(), status=201)
return True
+ def handle_cp(self) -> bool:
+ # full path of new loc (incl filename)
+ dst = self.uparam.get("copy")
+
+ if self.is_vproxied and dst and dst.startswith(self.args.SR):
+ dst = dst[len(self.args.RS) :]
+
+ if not dst:
+ raise Pebkac(400, "need dst vpath")
+
+ return self._cp(self.vpath, dst.lstrip("/"))
+
+ def _cp(self, vsrc: str, vdst: str) -> bool:
+ if self.args.no_cp:
+ raise Pebkac(403, "the copy feature is disabled in server config")
+
+ self.asrv.vfs.get(vsrc, self.uname, True, False)
+ self.asrv.vfs.get(vdst, self.uname, False, True)
+
+ x = self.conn.hsrv.broker.ask("up2k.handle_cp", self.uname, self.ip, vsrc, vdst)
+ self.loud_reply(x.get(), status=201)
+ return True
+
def tx_ls(self, ls: dict[str, Any]) -> bool:
dirs = ls["dirs"]
files = ls["files"]
diff --git a/copyparty/up2k.py b/copyparty/up2k.py
index 28ed66f2..e9056846 100644
--- a/copyparty/up2k.py
+++ b/copyparty/up2k.py
@@ -1464,7 +1464,7 @@ class Up2k(object):
t = "failed to index subdir [{}]:\n{}"
self.log(t.format(abspath, min_ex()), c=1)
elif not stat.S_ISREG(inf.st_mode):
- self.log("skip type-{:x} file [{}]".format(inf.st_mode, abspath))
+ self.log("skip type-0%o file [%s]" % (inf.st_mode, abspath))
else:
# self.log("file: {}".format(abspath))
if rp.endswith(".PARTIAL") and time.time() - lmod < 60:
@@ -3896,13 +3896,13 @@ class Up2k(object):
partial = ""
if not unpost:
permsets = [[True, False, False, True]]
- vn, rem = self.vfs.get(vpath, uname, *permsets[0])
- vn, rem = vn.get_dbv(rem)
+ vn0, rem0 = self.vfs.get(vpath, uname, *permsets[0])
+ vn, rem = vn0.get_dbv(rem0)
else:
# unpost with missing permissions? verify with db
permsets = [[False, True]]
- vn, rem = self.vfs.get(vpath, uname, *permsets[0])
- vn, rem = vn.get_dbv(rem)
+ vn0, rem0 = self.vfs.get(vpath, uname, *permsets[0])
+ vn, rem = vn0.get_dbv(rem0)
ptop = vn.realpath
with self.mutex, self.reg_mutex:
abrt_cfg = self.flags.get(ptop, {}).get("u2abort", 1)
@@ -3958,7 +3958,9 @@ class Up2k(object):
scandir = not self.args.no_scandir
if is_dir:
- g = vn.walk("", rem, [], uname, permsets, True, scandir, True)
+ # note: deletion inside shares would require a rewrite here;
+ # shares necessitate get_dbv which is incompatible with walk
+ g = vn0.walk("", rem0, [], uname, permsets, True, scandir, True)
if unpost:
raise Pebkac(400, "cannot unpost folders")
elif stat.S_ISLNK(st.st_mode) or stat.S_ISREG(st.st_mode):
@@ -3966,7 +3968,7 @@ class Up2k(object):
vpath_dir = vsplit(vpath)[0]
g = [(vn, voldir, vpath_dir, adir, [(fn, 0)], [], {})] # type: ignore
else:
- self.log("rm: skip type-{:x} file [{}]".format(st.st_mode, atop))
+ self.log("rm: skip type-0%o file [%s]" % (st.st_mode, atop))
return 0, [], []
xbd = vn.flags.get("xbd")
@@ -4066,17 +4068,226 @@ class Up2k(object):
return n_files, ok + ok2, ng + ng2
+ def handle_cp(self, uname: str, ip: str, svp: str, dvp: str) -> str:
+ if svp == dvp or dvp.startswith(svp + "/"):
+ raise Pebkac(400, "cp: cannot copy parent into subfolder")
+
+ svn, srem = self.vfs.get(svp, uname, True, False)
+ svn_dbv, _ = svn.get_dbv(srem)
+ sabs = svn.canonical(srem, False)
+ curs: set["sqlite3.Cursor"] = set()
+ self.db_act = self.vol_act[svn_dbv.realpath] = time.time()
+
+ st = bos.stat(sabs)
+ if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
+ with self.mutex:
+ try:
+ ret = self._cp_file(uname, ip, svp, dvp, curs)
+ finally:
+ for v in curs:
+ v.connection.commit()
+
+ return ret
+
+ if not stat.S_ISDIR(st.st_mode):
+ raise Pebkac(400, "cannot copy type-0%o file" % (st.st_mode,))
+
+ permsets = [[True, False]]
+ scandir = not self.args.no_scandir
+
+ # don't use svn_dbv; would skip subvols due to _ls `if not rem:`
+ g = svn.walk("", srem, [], uname, permsets, True, scandir, True)
+ with self.mutex:
+ try:
+ for dbv, vrem, _, atop, files, rd, vd in g:
+ for fn in files:
+ self.db_act = self.vol_act[dbv.realpath] = time.time()
+ svpf = "/".join(x for x in [dbv.vpath, vrem, fn[0]] if x)
+ if not svpf.startswith(svp + "/"): # assert
+ self.log(min_ex(), 1)
+ t = "cp: bug at %s, top %s%s"
+ raise Pebkac(500, t % (svpf, svp, SEESLOG))
+
+ dvpf = dvp + svpf[len(svp) :]
+ self._cp_file(uname, ip, svpf, dvpf, curs)
+
+ for v in curs:
+ v.connection.commit()
+ curs.clear()
+ finally:
+ for v in curs:
+ v.connection.commit()
+
+ return "k"
+
+ def _cp_file(
+ self, uname: str, ip: str, svp: str, dvp: str, curs: set["sqlite3.Cursor"]
+ ) -> str:
+ """mutex(main) me; will mutex(reg)"""
+ svn, srem = self.vfs.get(svp, uname, True, False)
+ svn_dbv, srem_dbv = svn.get_dbv(srem)
+
+ dvn, drem = self.vfs.get(dvp, uname, False, True)
+ dvn, drem = dvn.get_dbv(drem)
+
+ sabs = svn.canonical(srem, False)
+ dabs = dvn.canonical(drem)
+ drd, dfn = vsplit(drem)
+
+ if bos.path.exists(dabs):
+ raise Pebkac(400, "cp2: target file exists")
+
+ st = stl = bos.lstat(sabs)
+ if stat.S_ISLNK(stl.st_mode):
+ is_link = True
+ try:
+ st = bos.stat(sabs)
+ except:
+ pass # broken symlink; keep as-is
+ elif not stat.S_ISREG(st.st_mode):
+ self.log("skipping type-0%o file [%s]" % (st.st_mode, sabs))
+ return ""
+ else:
+ is_link = False
+
+ ftime = stl.st_mtime
+ fsize = st.st_size
+
+ xbc = svn.flags.get("xbc")
+ xac = dvn.flags.get("xac")
+ if xbc:
+ if not runhook(
+ self.log,
+ None,
+ self,
+ "xbc",
+ xbc,
+ sabs,
+ svp,
+ "",
+ uname,
+ self.vfs.get_perms(svp, uname),
+ ftime,
+ fsize,
+ ip,
+ time.time(),
+ "",
+ ):
+ t = "copy blocked by xbr server config: {}".format(svp)
+ self.log(t, 1)
+ raise Pebkac(405, t)
+
+ bos.makedirs(os.path.dirname(dabs))
+
+ c1, w, ftime_, fsize_, ip, at = self._find_from_vpath(
+ svn_dbv.realpath, srem_dbv
+ )
+ c2 = self.cur.get(dvn.realpath)
+
+ if w:
+ assert c1 # !rm
+ if c2 and c2 != c1:
+ self._copy_tags(c1, c2, w)
+
+ curs.add(c1)
+
+ if c2:
+ self.db_add(
+ c2,
+ {}, # skip upload hooks
+ drd,
+ dfn,
+ ftime,
+ fsize,
+ dvn.realpath,
+ dvn.vpath,
+ w,
+ w,
+ "",
+ "",
+ ip or "",
+ at or 0,
+ )
+ curs.add(c2)
+ else:
+ self.log("not found in src db: [{}]".format(svp))
+
+ try:
+ if is_link and st != stl:
+ # relink non-broken symlinks to still work after the move,
+ # but only resolve 1st level to maintain relativity
+ dlink = bos.readlink(sabs)
+ dlink = os.path.join(os.path.dirname(sabs), dlink)
+ dlink = bos.path.abspath(dlink)
+ self._symlink(dlink, dabs, dvn.flags, lmod=ftime)
+ else:
+ self._symlink(sabs, dabs, dvn.flags, lmod=ftime)
+
+ except OSError as ex:
+ if ex.errno != errno.EXDEV:
+ raise
+
+ self.log("using plain copy (%s):\n %s\n %s" % (ex.strerror, sabs, dabs))
+ b1, b2 = fsenc(sabs), fsenc(dabs)
+ is_link = os.path.islink(b1) # due to _relink
+ try:
+ shutil.copy2(b1, b2)
+ except:
+ try:
+ wunlink(self.log, dabs, dvn.flags)
+ except:
+ pass
+
+ if not is_link:
+ raise
+
+ # broken symlink? keep it as-is
+ try:
+ zb = os.readlink(b1)
+ os.symlink(zb, b2)
+ except:
+ wunlink(self.log, dabs, dvn.flags)
+ raise
+
+ if is_link:
+ try:
+ times = (int(time.time()), int(ftime))
+ bos.utime(dabs, times, False)
+ except:
+ pass
+
+ if xac:
+ runhook(
+ self.log,
+ None,
+ self,
+ "xac",
+ xac,
+ dabs,
+ dvp,
+ "",
+ uname,
+ self.vfs.get_perms(dvp, uname),
+ ftime,
+ fsize,
+ ip,
+ time.time(),
+ "",
+ )
+
+ return "k"
+
def handle_mv(self, uname: str, ip: str, svp: str, dvp: str) -> str:
if svp == dvp or dvp.startswith(svp + "/"):
raise Pebkac(400, "mv: cannot move parent into subfolder")
svn, srem = self.vfs.get(svp, uname, True, False, True)
- svn, srem = svn.get_dbv(srem)
+ jail, jail_rem = svn.get_dbv(srem)
sabs = svn.canonical(srem, False)
curs: set["sqlite3.Cursor"] = set()
- self.db_act = self.vol_act[svn.realpath] = time.time()
+ self.db_act = self.vol_act[jail.realpath] = time.time()
- if not srem:
+ if not jail_rem:
raise Pebkac(400, "mv: cannot move a mountpoint")
st = bos.lstat(sabs)
@@ -4090,7 +4301,9 @@ class Up2k(object):
return ret
- jail = svn.get_dbv(srem)[0]
+ if not stat.S_ISDIR(st.st_mode):
+ raise Pebkac(400, "cannot move type-0%o file" % (st.st_mode,))
+
permsets = [[True, False, True]]
scandir = not self.args.no_scandir
@@ -4102,13 +4315,13 @@ class Up2k(object):
raise Pebkac(400, "mv: source folder contains other volumes")
g = svn.walk("", srem, [], uname, permsets, True, scandir, True)
- for dbv, vrem, _, atop, files, rd, vd in g:
- if dbv != jail:
- # the actual check (avoid toctou)
- raise Pebkac(400, "mv: source folder contains other volumes")
+ with self.mutex:
+ try:
+ for dbv, vrem, _, atop, files, rd, vd in g:
+ if dbv != jail:
+ # the actual check (avoid toctou)
+ raise Pebkac(400, "mv: source folder contains other volumes")
- with self.mutex:
- try:
for fn in files:
self.db_act = self.vol_act[dbv.realpath] = time.time()
svpf = "/".join(x for x in [dbv.vpath, vrem, fn[0]] if x)
@@ -4119,11 +4332,13 @@ class Up2k(object):
dvpf = dvp + svpf[len(svp) :]
self._mv_file(uname, ip, svpf, dvpf, curs)
- finally:
+
for v in curs:
v.connection.commit()
-
- curs.clear()
+ curs.clear()
+ finally:
+ for v in curs:
+ v.connection.commit()
rm_ok, rm_ng = rmdirs(self.log_func, scandir, True, sabs, 1)
diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js
index 75c9e451..15818625 100644
--- a/copyparty/web/browser.js
+++ b/copyparty/web/browser.js
@@ -37,8 +37,9 @@ var Ls = {
["T", "toggle thumbnails / icons"],
["🡅 A/D", "thumbnail size"],
["ctrl-K", "delete selected"],
- ["ctrl-X", "cut selected"],
- ["ctrl-V", "paste into folder"],
+ ["ctrl-X", "cut selection to clipboard"],
+ ["ctrl-C", "copy selection to clipboard"],
+ ["ctrl-V", "paste (move/copy) here"],
["Y", "download selected"],
["F2", "rename selected"],
@@ -83,7 +84,7 @@ var Ls = {
["I/K", "prev/next file"],
["M", "close textfile"],
["E", "edit textfile"],
- ["S", "select file (for cut/rename)"],
+ ["S", "select file (for cut/copy/rename)"],
]
],
@@ -133,6 +134,7 @@ var Ls = {
"wt_ren": "rename selected items$NHotkey: F2",
"wt_del": "delete selected items$NHotkey: ctrl-K",
"wt_cut": "cut selected items <small>(then paste somewhere else)</small>$NHotkey: ctrl-X",
+ "wt_cpy": "copy selected items to clipboard$N(to paste them somewhere else)$NHotkey: ctrl-C",
"wt_pst": "paste a previously cut / copied selection$NHotkey: ctrl-V",
"wt_selall": "select all files$NHotkey: ctrl-A (when file focused)",
"wt_selinv": "invert selection",
@@ -327,6 +329,7 @@ var Ls = {
"fr_emore": "select at least one item to rename",
"fd_emore": "select at least one item to delete",
"fc_emore": "select at least one item to cut",
+ "fcp_emore": "select at least one item to copy to clipboard",
"fs_sc": "share the folder you're in",
"fs_ss": "share the selected files",
@@ -379,16 +382,26 @@ var Ls = {
"fc_ok": "cut {0} items",
"fc_warn": 'cut {0} items\n\nbut: only this browser-tab can paste them\n(since the selection is so absolutely massive)',
- "fp_ecut": "first cut some files / folders to paste / move\n\nnote: you can cut / paste across different browser tabs",
+ "fcc_ok": "copied {0} items to clipboard",
+ "fcc_warn": 'copied {0} items to clipboard\n\nbut: only this browser-tab can paste them\n(since the selection is so absolutely massive)',
+
+ "fp_ecut": "first cut or copy some files / folders to paste / move\n\nnote: you can cut / paste across different browser tabs",
"fp_ename": "these {0} items cannot be moved here (names already exist):",
+ "fcp_ename": "these {0} items cannot be copied here (names already exist):",
"fp_ok": "move OK",
+ "fcp_ok": "copy OK",
"fp_busy": "moving {0} items...\n\n{1}",
+ "fcp_busy": "copying {0} items...\n\n{1}",
"fp_err": "move failed:\n",
+ "fcp_err": "copy failed:\n",
"fp_confirm": "move these {0} items here?",
+ "fcp_confirm": "copy these {0} items here?",
"fp_etab": 'failed to read clipboard from other browser tab',
"fp_name": "uploading a file from your device. Give it a name:",
"fp_both_m": 'choose what to paste
Enter
= Move {0} files from «{1}»\nESC
= Upload {2} files from your device',
+ "fcp_both_m": 'choose what to paste
Enter
= Copy {0} files from «{1}»\nESC
= Upload {2} files from your device',
"fp_both_b": 'MoveUpload',
+ "fcp_both_b": 'CopyUpload',
"mk_noname": "type a name into the text field on the left before you do that :p",
@@ -400,7 +413,7 @@ var Ls = {
"tvt_dl": "download this file$NHotkey: Y\">💾 download",
"tvt_prev": "show previous document$NHotkey: i\">⬆ prev",
"tvt_next": "show next document$NHotkey: K\">⬇ next",
- "tvt_sel": "select file ( for cut / delete / ... )$NHotkey: S\">sel",
+ "tvt_sel": "select file ( for cut / copy / delete / ... )$NHotkey: S\">sel",
"tvt_edit": "open file in text editor$NHotkey: E\">✏️ edit",
"gt_vau": "don't show videos, just play the audio\">🎧",
@@ -605,8 +618,9 @@ var Ls = {
["T", "miniatyrbilder på/av"],
["🡅 A/D", "ikonstørrelse"],
["ctrl-K", "slett valgte"],
- ["ctrl-X", "klipp ut"],
- ["ctrl-V", "lim inn"],
+ ["ctrl-X", "klipp ut valgte"],
+ ["ctrl-C", "kopiér til utklippstavle"],
+ ["ctrl-V", "lim inn (flytt/kopiér)"],
["Y", "last ned valgte"],
["F2", "endre navn på valgte"],
@@ -702,7 +716,8 @@ var Ls = {
"wt_ren": "gi nye navn til de valgte filene$NSnarvei: F2",
"wt_del": "slett de valgte filene$NSnarvei: ctrl-K",
"wt_cut": "klipp ut de valgte filene <small>(for å lime inn et annet sted)</small>$NSnarvei: ctrl-X",
- "wt_pst": "lim inn filer (som tidligere ble klippet ut et annet sted)$NSnarvei: ctrl-V",
+ "wt_cpy": "kopiér de valgte filene til utklippstavlen$N(for å lime inn et annet sted)$NSnarvei: ctrl-C",
+ "wt_pst": "lim inn filer (som tidligere ble klippet ut / kopiert et annet sted)$NSnarvei: ctrl-V",
"wt_selall": "velg alle filer$NSnarvei: ctrl-A (mens fokus er på en fil)",
"wt_selinv": "inverter utvalg",
"wt_selzip": "last ned de valgte filene som et arkiv",
@@ -845,7 +860,7 @@ var Ls = {
"mt_oscv": "vis album-cover på infoskjermen\">bilde",
"mt_follow": "bla slik at sangen som spilles alltid er synlig\">🎯",
"mt_compact": "tettpakket avspillerpanel\">⟎",
- "mt_uncache": "prøv denne hvis en sang ikke spiller riktig\">uncache",
+ "mt_uncache": "prøv denne hvis en sang ikke spiller riktig\">oppfrisk",
"mt_mloop": "repeter hele mappen\">🔁 gjenta",
"mt_mnext": "hopp til neste mappe og fortsett\">📂 neste",
"mt_cflac": "konverter flac / wav-filer til opus\">flac",
@@ -896,6 +911,7 @@ var Ls = {
"fr_emore": "velg minst én fil som skal få nytt navn",
"fd_emore": "velg minst én fil som skal slettes",
"fc_emore": "velg minst én fil som skal klippes ut",
+ "fcp_emore": "velg minst én fil som skal kopieres til utklippstavlen",
"fs_sc": "del mappen du er i nå",
"fs_ss": "del de valgte filene",
@@ -948,16 +964,26 @@ var Ls = {
"fc_ok": "klippet ut {0} filer",
"fc_warn": 'klippet ut {0} filer\n\nmen: kun denne nettleserfanen har mulighet til å lime dem inn et annet sted, siden antallet filer er helt hinsides',
- "fp_ecut": "du må klippe ut noen filer / mapper først\n\nmerk: du kan gjerne jobbe på kryss av nettleserfaner; klippe ut i én fane, lime inn i en annen",
+ "fcc_ok": "kopierte {0} filer til utklippstavlen",
+ "fcc_warn": 'kopierte {0} filer til utklippstavlen\n\nmen: kun denne nettleserfanen har mulighet til å lime dem inn et annet sted, siden antallet filer er helt hinsides',
+
+ "fp_ecut": "du må klippe ut eller kopiere noen filer / mapper først\n\nmerk: du kan gjerne jobbe på kryss av nettleserfaner; klippe ut i én fane, lime inn i en annen",
"fp_ename": "disse {0} filene kan ikke flyttes til målmappen fordi det allerede finnes filer med samme navn:",
+ "fcp_ename": "disse {0} filene kan ikke kopieres til målmappen fordi det allerede finnes filer med samme navn:",
"fp_ok": "flytting OK",
+ "fcp_ok": "kopiering OK",
"fp_busy": "flytter {0} filer...\n\n{1}",
+ "fcp_busy": "kopierer {0} filer...\n\n{1}",
"fp_err": "flytting feilet:\n",
+ "fcp_err": "kopiering feilet:\n",
"fp_confirm": "flytt disse {0} filene hit?",
+ "fcp_confirm": "kopiér disse {0} filene hit?",
"fp_etab": 'kunne ikke lese listen med filer ifra den andre nettleserfanen',
"fp_name": "Laster opp én fil fra enheten din. Velg filnavn:",
"fp_both_m": 'hva skal limes inn her?
Enter
= Flytt {0} filer fra «{1}»\nESC
= Last opp {2} filer fra enheten din',
+ "fcp_both_m": 'hva skal limes inn her?
Enter
= Kopiér {0} filer fra «{1}»\nESC
= Last opp {2} filer fra enheten din',
"fp_both_b": 'FlyttLast opp',
+ "fcp_both_b": 'KopiérLast opp',
"mk_noname": "skriv inn et navn i tekstboksen til venstre først :p",
@@ -1176,6 +1202,7 @@ var Ls = {
["🡅 A/D", "缩略图大小"],
["ctrl-K", "删除选中项"],
["ctrl-X", "剪切选中项"],
+ ["ctrl-C", "复制选中项"], //m
["ctrl-V", "粘贴到文件夹"],
["Y", "下载选中项"],
["F2", "重命名选中项"],
@@ -1271,6 +1298,7 @@ var Ls = {
"wt_ren": "重命名选中的项目$N快捷键: F2",
"wt_del": "删除选中的项目$N快捷键: ctrl-K",
"wt_cut": "剪切选中的项目<small>(然后粘贴到其他地方)</small>$N快捷键: ctrl-X",
+ "wt_cpy": "将选中的项目复制到剪贴板<small>(然后粘贴到其他地方)</small>$N快捷键: ctrl-C", //m
"wt_pst": "粘贴之前剪切/复制的选择$N快捷键: ctrl-V",
"wt_selall": "选择所有文件$N快捷键: ctrl-A(当文件被聚焦时)",
"wt_selinv": "反转选择",
@@ -1465,6 +1493,7 @@ var Ls = {
"fr_emore": "选择至少一个项目以重命名",
"fd_emore": "选择至少一个项目以删除",
"fc_emore": "选择至少一个项目以剪切",
+ "fcp_emore": "选择至少一个要复制到剪贴板的项目", //m
"fs_sc": "分享你所在的文件夹",
"fs_ss": "分享选定的文件",
@@ -1517,16 +1546,26 @@ var Ls = {
"fc_ok": "剪切 {0} 项",
"fc_warn": '剪切 {0} 项\n\n但:只有 这个 浏览器标签页可以粘贴它们\n(因为选择非常庞大)',
- "fp_ecut": "首先剪切一些文件/文件夹以粘贴/移动\n\n注意:你可以在不同的浏览器标签页之间剪切/粘贴",
+ "fcc_ok": "已将 {0} 项复制到剪贴板", //m
+ "fcc_warn": '已将 {0} 项复制到剪贴板\n\n但:只有 这个 浏览器标签页可以粘贴它们\n(因为选择非常庞大)', //m
+
+ "fp_ecut": "首先剪切或复制一些文件/文件夹以粘贴/移动\n\n注意:你可以在不同的浏览器标签页之间剪切/粘贴", //m
"fp_ename": "这些 {0} 项不能移动到这里(名称已存在):",
+ "fcp_ename": "这些 {0} 项不能复制到这里(名称已存在):", //m
"fp_ok": "移动成功",
+ "fcp_ok": "复制成功", //m
"fp_busy": "正在移动 {0} 项...\n\n{1}",
+ "fcp_busy": "正在复制 {0} 项...\n\n{1}", //m
"fp_err": "移动失败:\n",
+ "fcp_err": "复制失败:\n", //m
"fp_confirm": "将这些 {0} 项移动到这里?",
+ "fcp_confirm": "将这些 {0} 项复制到这里?", //m
"fp_etab": '无法从其他浏览器标签页读取剪贴板',
"fp_name": "从你的设备上传一个文件。给它一个名字:",
"fp_both_m": '选择粘贴内容
Enter
= 从 «{1}» 移动 {0} 个文件\nESC
= 从你的设备上传 {2} 个文件',
+ "fcp_both_m": '选择粘贴内容
Enter
= 从 «{1}» 复制 {0} 个文件\nESC
= 从你的设备上传 {2} 个文件', //m
"fp_both_b": '移动上传',
+ "fcp_both_b": '复制上传', //m
"mk_noname": "在左侧文本框中输入名称,然后再执行此操作 :p",
@@ -1771,6 +1810,7 @@ ebi('widget').innerHTML = (
' href="#" id="fren" tt="' + L.wt_ren + '">✎name⌫del.✂cut⧉copy📋paste' +
'sel.
all 1024 * 1024)
throw 'a';
@@ -5062,6 +5112,59 @@ var fileman = (function () {
}
};
+ r.cpy = function (e) {
+ ev(e);
+ var sel = msel.getsel(),
+ stamp = Date.now(),
+ vps = [stamp, '//c'];
+
+ if (!sel.length)
+ return toast.err(3, L.fcp_emore);
+
+ var els = [], griden = thegrid.en;
+ for (var a = 0; a < sel.length; a++) {
+ vps.push(sel[a].vp);
+ if (sel.length < 100)
+ try {
+ if (griden)
+ els.push(QS('#ggrid>a[ref="' + sel[a].id + '"]'));
+ else
+ els.push(ebi(sel[a].id).closest('tr'));
+
+ clmod(els[a], 'fcut');
+ }
+ catch (ex) { }
+ }
+
+ setTimeout(function () {
+ try {
+ for (var a = 0; a < els.length; a++)
+ clmod(els[a], 'fcut', 1);
+ }
+ catch (ex) { }
+ }, 1);
+
+ if (vps.length < 3)
+ vps.pop();
+
+ r.ccp = true;
+ r.clip = vps.slice(2);
+
+ try {
+ vps = JSON.stringify(vps);
+ if (vps.length > 1024 * 1024)
+ throw 'a';
+
+ swrite('fman_clip', vps);
+ r.tx(stamp);
+ if (sel.length)
+ toast.inf(1.5, L.fcc_ok.format(sel.length));
+ }
+ catch (ex) {
+ toast.warn(30, L.fcc_warn.format(sel.length));
+ }
+ };
+
document.onpaste = function (e) {
var xfer = e.clipboardData || window.clipboardData;
if (!xfer || !xfer.files || !xfer.files.length)
@@ -5077,9 +5180,9 @@ var fileman = (function () {
return r.clip_up(files);
var src = r.clip.length == 1 ? r.clip[0] : vsplit(r.clip[0])[0],
- msg = L.fp_both_m.format(r.clip.length, src, files.length);
+ msg = (r.ccp ? L.fcp_both_m : L.fp_both_m).format(r.clip.length, src, files.length);
- modal.confirm(msg, r.paste, function () { r.clip_up(files); }, null, L.fp_both_b);
+ modal.confirm(msg, r.paste, function () { r.clip_up(files); }, null, (r.ccp ? L.fcp_both_b : L.fp_both_b));
};
r.clip_up = function (files) {
@@ -5157,7 +5260,7 @@ var fileman = (function () {
}
if (exists.length)
- toast.warn(30, L.fp_ename.format(exists.length) + '' + uricom_adec(exists, true).join('') + '
');
+ toast.warn(30, (r.ccp ? L.fcp_ename : L.fp_ename).format(exists.length) + '' + uricom_adec(exists, true).join('') + '
');
if (!req.length)
return;
@@ -5167,29 +5270,30 @@ var fileman = (function () {
vp = req.shift();
if (!vp) {
- toast.ok(2, L.fp_ok);
+ toast.ok(2, r.ccp ? L.fcp_ok : L.fp_ok);
treectl.goto();
r.tx(srcdir);
return;
}
- toast.show('inf r', 0, esc(L.fp_busy.format(req.length + 1, uricom_dec(vp))));
+ toast.show('inf r', 0, esc((r.ccp ? L.fcp_busy : L.fp_busy).format(req.length + 1, uricom_dec(vp))));
- var dst = get_evpath() + vp.split('/').pop();
+ var act = r.ccp ? '?copy=' : '?move=',
+ dst = get_evpath() + vp.split('/').pop();
- xhr.open('POST', vp + '?move=' + dst, true);
+ xhr.open('POST', vp + act + dst, true);
xhr.onload = xhr.onerror = paste_cb;
xhr.send();
}
function paste_cb() {
if (this.status !== 201) {
var msg = unpre(this.responseText);
- toast.err(9, L.fp_err + msg);
+ toast.err(9, (r.ccp ? L.fcp_err : L.fp_err) + msg);
return;
}
paster();
}
- modal.confirm(L.fp_confirm.format(req.length) + '' + uricom_adec(req, true).join('') + '
', function () {
+ modal.confirm((r.ccp ? L.fcp_confirm : L.fp_confirm).format(req.length) + '' + uricom_adec(req, true).join('') + '
', function () {
paster();
jwrite('fman_clip', [Date.now()]);
}, null);
@@ -5231,6 +5335,7 @@ var fileman = (function () {
bren.onclick = r.rename;
bdel.onclick = r.delete;
bcut.onclick = r.cut;
+ bcpy.onclick = r.cpy;
bpst.onclick = r.paste;
bshr.onclick = r.share;
@@ -6326,9 +6431,15 @@ var ahotkeys = function (e) {
return hkhelp();
if (ctrl(e)) {
+ var sel = window.getSelection && window.getSelection() || {};
+ sel = sel && !sel.isCollapsed && sel.direction != 'none';
+
if (k == 'KeyX' || k == 'x')
return fileman.cut();
+ if ((k == 'KeyC' || k == 'c') && !sel)
+ return fileman.cpy();
+
if (k == 'KeyV' || k == 'v')
return fileman.d_paste();
diff --git a/docs/devnotes.md b/docs/devnotes.md
index 71f3bba8..6d39704a 100644
--- a/docs/devnotes.md
+++ b/docs/devnotes.md
@@ -163,6 +163,7 @@ authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo`
| method | params | result |
|--|--|--|
+| POST | `?copy=/foo/bar` | copy the file/folder at URL to /foo/bar |
| POST | `?move=/foo/bar` | move/rename the file/folder at URL to /foo/bar |
| method | params | body | result |
diff --git a/scripts/tl.js b/scripts/tl.js
index aa1f5bd1..22cde3fa 100644
--- a/scripts/tl.js
+++ b/scripts/tl.js
@@ -121,8 +121,9 @@ var tl_browser = {
["T", "toggle thumbnails / icons"],
["🡅 A/D", "thumbnail size"],
["ctrl-K", "delete selected"],
- ["ctrl-X", "cut selected"],
- ["ctrl-V", "paste into folder"],
+ ["ctrl-X", "cut selection to clipboard"],
+ ["ctrl-C", "copy selection to clipboard"],
+ ["ctrl-V", "paste (move/copy) here"],
["Y", "download selected"],
["F2", "rename selected"],
@@ -167,7 +168,7 @@ var tl_browser = {
["I/K", "prev/next file"],
["M", "close textfile"],
["E", "edit textfile"],
- ["S", "select file (for cut/rename)"],
+ ["S", "select file (for cut/copy/rename)"],
]
],
@@ -217,6 +218,7 @@ var tl_browser = {
"wt_ren": "rename selected items$NHotkey: F2",
"wt_del": "delete selected items$NHotkey: ctrl-K",
"wt_cut": "cut selected items <small>(then paste somewhere else)</small>$NHotkey: ctrl-X",
+ "wt_cpy": "copy selected items to clipboard$N(to paste them somewhere else)$NHotkey: ctrl-C",
"wt_pst": "paste a previously cut / copied selection$NHotkey: ctrl-V",
"wt_selall": "select all files$NHotkey: ctrl-A (when file focused)",
"wt_selinv": "invert selection",
@@ -411,6 +413,7 @@ var tl_browser = {
"fr_emore": "select at least one item to rename",
"fd_emore": "select at least one item to delete",
"fc_emore": "select at least one item to cut",
+ "fcp_emore": "select at least one item to copy",
"fs_sc": "share the folder you're in",
"fs_ss": "share the selected files",
@@ -463,16 +466,26 @@ var tl_browser = {
"fc_ok": "cut {0} items",
"fc_warn": 'cut {0} items\n\nbut: only this browser-tab can paste them\n(since the selection is so absolutely massive)',
- "fp_ecut": "first cut some files / folders to paste / move\n\nnote: you can cut / paste across different browser tabs",
+ "fcc_ok": "copied {0} items to clipboard",
+ "fcc_warn": 'copied {0} items to clipboard\n\nbut: only this browser-tab can paste them\n(since the selection is so absolutely massive)',
+
+ "fp_ecut": "first cut or copy some files / folders to paste / move\n\nnote: you can cut / paste across different browser tabs",
"fp_ename": "these {0} items cannot be moved here (names already exist):",
+ "fcp_ename": "these {0} items cannot be copied here (names already exist):",
"fp_ok": "move OK",
+ "fcp_ok": "copy OK",
"fp_busy": "moving {0} items...\n\n{1}",
+ "fcp_busy": "copying {0} items...\n\n{1}",
"fp_err": "move failed:\n",
+ "fcp_err": "copy failed:\n",
"fp_confirm": "move these {0} items here?",
+ "fcp_confirm": "copy these {0} items here?",
"fp_etab": 'failed to read clipboard from other browser tab',
"fp_name": "uploading a file from your device. Give it a name:",
"fp_both_m": 'choose what to paste
Enter
= Move {0} files from «{1}»\nESC
= Upload {2} files from your device',
+ "fcp_both_m": 'choose what to paste
Enter
= Copy {0} files from «{1}»\nESC
= Upload {2} files from your device',
"fp_both_b": 'MoveUpload',
+ "fcp_both_b": 'CopyUpload',
"mk_noname": "type a name into the text field on the left before you do that :p",
@@ -484,7 +497,7 @@ var tl_browser = {
"tvt_dl": "download this file$NHotkey: Y\">💾 download",
"tvt_prev": "show previous document$NHotkey: i\">⬆ prev",
"tvt_next": "show next document$NHotkey: K\">⬇ next",
- "tvt_sel": "select file ( for cut / delete / ... )$NHotkey: S\">sel",
+ "tvt_sel": "select file ( for cut / copy / delete / ... )$NHotkey: S\">sel",
"tvt_edit": "open file in text editor$NHotkey: E\">✏️ edit",
"gt_vau": "don't show videos, just play the audio\">🎧",
diff --git a/tests/test_cp.py b/tests/test_cp.py
new file mode 100644
index 00000000..7e0f2010
--- /dev/null
+++ b/tests/test_cp.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+# coding: utf-8
+from __future__ import print_function, unicode_literals
+
+import os
+import shutil
+import tempfile
+import unittest
+from itertools import product
+
+from copyparty.authsrv import AuthSrv
+from copyparty.httpcli import HttpCli
+from tests import util as tu
+from tests.util import Cfg
+
+
+class TestDedup(unittest.TestCase):
+ def setUp(self):
+ self.td = tu.get_ramdisk()
+
+ def tearDown(self):
+ os.chdir(tempfile.gettempdir())
+ shutil.rmtree(self.td)
+
+ def reset(self):
+ td = os.path.join(self.td, "vfs")
+ if os.path.exists(td):
+ shutil.rmtree(td)
+ os.mkdir(td)
+ os.chdir(td)
+ for a in "abc":
+ os.mkdir(a)
+ for b in "fg":
+ d = "%s/%s%s" % (a, a, b)
+ os.mkdir(d)
+ for fn in "x":
+ fp = "%s/%s%s%s" % (d, a, b, fn)
+ with open(fp, "wb") as f:
+ f.write(fp.encode("utf-8"))
+ return td
+
+ def cinit(self):
+ if self.conn:
+ self.fstab = self.conn.hsrv.hub.up2k.fstab
+ self.conn.hsrv.hub.up2k.shutdown()
+ self.asrv = AuthSrv(self.args, self.log)
+ self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"", True)
+ if self.fstab:
+ self.conn.hsrv.hub.up2k.fstab = self.fstab
+
+ def test(self):
+ tc_dedup = ["sym", "no"]
+ vols = [".::A", "a/af:a/af:r", "b:a/b:r"]
+ tcs = [
+ "/a?copy=/c/a /a/af/afx /a/ag/agx /a/b/bf/bfx /a/b/bg/bgx /b/bf/bfx /b/bg/bgx /c/a/af/afx /c/a/ag/agx /c/a/b/bf/bfx /c/a/b/bg/bgx /c/cf/cfx /c/cg/cgx",
+ "/b?copy=/d /a/af/afx /a/ag/agx /a/b/bf/bfx /a/b/bg/bgx /b/bf/bfx /b/bg/bgx /c/cf/cfx /c/cg/cgx /d/bf/bfx /d/bg/bgx",
+ "/b/bf?copy=/d /a/af/afx /a/ag/agx /a/b/bf/bfx /a/b/bg/bgx /b/bf/bfx /b/bg/bgx /c/cf/cfx /c/cg/cgx /d/bfx",
+ "/a/af?copy=/d /a/af/afx /a/ag/agx /a/b/bf/bfx /a/b/bg/bgx /b/bf/bfx /b/bg/bgx /c/cf/cfx /c/cg/cgx /d/afx",
+ "/a/af?copy=/ /a/af/afx /a/ag/agx /a/b/bf/bfx /a/b/bg/bgx /afx /b/bf/bfx /b/bg/bgx /c/cf/cfx /c/cg/cgx",
+ "/a/af/afx?copy=/afx /a/af/afx /a/ag/agx /a/b/bf/bfx /a/b/bg/bgx /afx /b/bf/bfx /b/bg/bgx /c/cf/cfx /c/cg/cgx",
+ ]
+
+ self.conn = None
+ self.fstab = None
+ self.ctr = 0 # 2304
+ for dedup, act_exp in product(tc_dedup, tcs):
+ action, expect = act_exp.split(" ", 1)
+ t = "dedup:%s action:%s" % (dedup, action)
+ print("\n\n\033[0;7m# ", t, "\033[0m")
+
+ ka = {"dav_inf": True}
+ if dedup == "hard":
+ ka["hardlink"] = True
+ elif dedup == "no":
+ ka["no_dedup"] = True
+
+ self.args = Cfg(v=vols, a=[], **ka)
+ self.reset()
+ self.cinit()
+
+ self.do_cp(action)
+ zs = self.propfind()
+
+ fns = " ".join(zs[1])
+ self.assertEqual(expect, fns)
+
+ def do_cp(self, action):
+ hdr = "POST %s HTTP/1.1\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
+ buf = (hdr % (action,)).encode("utf-8")
+ print("CP [%s]" % (action,))
+ HttpCli(self.conn.setbuf(buf)).run()
+ ret = self.conn.s._reply.decode("utf-8").split("\r\n\r\n", 1)
+ print("CP <-- ", ret)
+ self.assertIn(" 201 Created", ret[0])
+ self.assertEqual("k\r\n", ret[1])
+ return ret
+
+ def propfind(self):
+ h = "PROPFIND / HTTP/1.1\r\nConnection: close\r\n\r\n"
+ HttpCli(self.conn.setbuf(h.encode("utf-8"))).run()
+ h, t = self.conn.s._reply.decode("utf-8").split("\r\n\r\n", 1)
+ fns = t.split("")[1:]
+ fns = [x.split("