diff --git a/.vscode/launch.json b/.vscode/launch.json index a2cce62f..d3704cf1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "cwd": "${workspaceFolder}", "args": [ "-j", - "2", + "0", "-nc", "4", "-nw", diff --git a/.vscode/settings.json b/.vscode/settings.json index e701b13b..4e813845 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "workbench.colorCustomizations": { + // https://ocv.me/dot/bifrost.html "terminal.background": "#1e1e1e", "terminal.foreground": "#d2d2d2", "terminalCursor.background": "#93A1A1", diff --git a/README.md b/README.md index f97dd396..af049867 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ turn your phone or raspi into a portable file server with resumable uploads/downloads using IE6 or any other browser -* resumable uploads need `firefox 12+` / `chrome 6+` / `safari 6+` / `IE 10+` -* server runs on everything with `py2.7` or `py3.3+` +* server runs on anything with `py2.7` or `py3.3+` +* *resumable* uploads need `firefox 12+` / `chrome 6+` / `safari 6+` / `IE 10+` * code standard: `black` ## status diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index c010f388..9704b72a 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -8,6 +8,48 @@ import threading from .__init__ import * +class VFS(object): + """single level in the virtual fs""" + + def __init__(self, realpath, vpath, uread=[], uwrite=[]): + self.realpath = realpath # absolute path on host filesystem + self.vpath = vpath # absolute path in the virtual filesystem + self.uread = uread # users who can read this + self.uwrite = uwrite # users who can write this + self.nodes = {} # child nodes + + def add(self, src, dst): + """get existing, or add new path to the vfs""" + assert not src.endswith("/") + assert not dst.endswith("/") + + if "/" in dst: + # requires breadth-first population (permissions trickle down) + name, dst = dst.split("/", 1) + if name in self.nodes: + # exists; do not manipulate permissions + return self.nodes[name].add(src, dst) + + n = VFS( + "{}/{}".format(self.realpath, name), + "{}/{}".format(self.vpath, name).lstrip("/"), + self.uread, + self.uwrite, + ) + self.nodes[name] = n + return n.add(src, dst) + + if dst in self.nodes: + # leaf exists; return as-is + return self.nodes[dst] + + # leaf does not exist; create and keep permissions blank + vp = "{}/{}".format(self.vpath, dst).lstrip("/") + n = VFS(src, vp) + self.nodes[dst] = n + return n + + class AuthSrv(object): """verifies users against given paths""" @@ -28,9 +70,16 @@ class AuthSrv(object): return {v: k for k, v in orig.items()} def reload(self): + """ + construct a flat list of mountpoints and usernames + first from the commandline arguments + then supplementing with config files + before finally building the VFS + """ + user = {} # username:password - uread = {} # username:readable-mp - uwrite = {} # username:writable-mp + mread = {} # mountpoint:[username] + mwrite = {} # mountpoint:[username] mount = {} # dst:src (mountpoint:realpath) if self.args.a: @@ -43,16 +92,19 @@ class AuthSrv(object): # permset is [rwa]username for src, dst, perms in [x.split(":", 2) for x in self.args.v]: src = os.path.abspath(src) - dst = ("/" + dst.strip("/") + "/").replace("//", "/") + dst = dst.strip("/") mount[dst] = src + mread[dst] = [] + mwrite[dst] = [] + perms = perms.split(":") for (lvl, uname) in [[x[0], x[1:]] for x in perms]: if uname == "": uname = "*" if lvl in "ra": - uread[uname] = dst + mread[dst].append(uname) if lvl in "wa": - uwrite[uname] = dst + mwrite[dst].append(uname) if self.args.c: for logfile in self.args.c: @@ -61,21 +113,27 @@ class AuthSrv(object): # self.log(ln) pass - with self.mutex: - self.user = user - self.uread = uread - self.uwrite = uwrite - self.mount = mount - self.iuser = self.invert(user) - self.iuread = self.invert(uread) - self.iuwrite = self.invert(uwrite) - self.imount = self.invert(mount) + # -h says our defaults are CWD at root and read/write for everyone + vfs = VFS(os.path.abspath("."), "", ["*"], ["*"]) - pprint.pprint( - { - "user": self.user, - "uread": self.uread, - "uwrite": self.uwrite, - "mount": self.mount, - } - ) + maxdepth = 0 + for dst in sorted(mount.keys(), key=lambda x: (x.count("/"), len(x))): + depth = dst.count("/") + assert maxdepth <= depth + maxdepth = depth + + if dst == "": + # rootfs was mapped; fully replaces the default CWD vfs + vfs = VFS(mount[dst], dst, mread[dst], mwrite[dst]) + continue + + v = vfs.add(mount[dst], dst) + v.uread = mread[dst] + v.uwrite = mwrite[dst] + + with self.mutex: + self.vfs = vfs + self.user = user + self.iuser = self.invert(user) + + # pprint.pprint({"usr": user, "rd": mread, "wr": mwrite, "mnt": mount}) diff --git a/docs/example.conf b/docs/example.conf index 26577810..4292ab58 100644 --- a/docs/example.conf +++ b/docs/example.conf @@ -1,38 +1,41 @@ -# any line with a : creates a user, -# username:password -# so you can create users anywhere really -# but keeping them here is prob a good idea -ed:123 -k:k +# create users: +# u username:password +u ed:123 +u k:k -# leave a blank line before each volume +# leave a blank line between volumes +# (and also between users and volumes) -# this is a volume, -# it shares the contents of /home/... -# and appears at "/dj" in the web-ui +# create a volume: +# share "." (the current directory) +# as "/" (the webroot) for the following users: # "r" grants read-access for anyone # "a ed" grants read-write to ed -/home/ed/Music/dj -/dj +. +/ r a ed -# display /home/ed/ocv.me as the webroot -# and allow user "k" to see/read it -/home/ed/ocv.me -/ +# custom permissions for the "priv" folder: +# user "k" can see/read the contents +# and "ed" gets read-write access +./priv +/priv r k +a ed -# this shares the current directory as "/pwd" -# but does nothing since there's no permissions -. -/pwd +# share /home/ed/Music/ as /music and let anyone read it +# (this will replace any folder called "music" in the webroot) +/home/ed/Music +/music +r # and a folder where anyone can upload # but nobody can see the contents /home/ed/inc -/incoming +/dump w -# you can use relative paths too btw -# but they're a pain for testing purpose so I didn't +# this entire config file can be replaced with these arguments: +# -u ed:123 -u k:k -v .::r:aed -v priv:priv:rk:aed -v /home/ed/Music:music:r -v /home/ed/inc:dump:w +# but note that the config file always wins in case of conflicts diff --git a/tests/test_vfs.py b/tests/test_vfs.py new file mode 100644 index 00000000..0a3b28f8 --- /dev/null +++ b/tests/test_vfs.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +# coding: utf-8 +from __future__ import print_function + +import os +import json +import shutil +import unittest + +from argparse import Namespace +from copyparty.authsrv import * + + +class TestVFS(unittest.TestCase): + def dump(self, vfs): + print(json.dumps(vfs, indent=4, sort_keys=True, default=lambda o: o.__dict__)) + + def test(self): + td = "/dev/shm/vfs" + try: + shutil.rmtree(td) + except: + pass + + os.mkdir(td) + os.chdir(td) + + for a in "abc": + for b in "abc": + for c in "abc": + folder = "{0}/{0}{1}/{0}{1}{2}".format(a, b, c) + os.makedirs(folder) + for d in "abc": + fn = "{}/{}{}{}{}".format(folder, a, b, c, d) + with open(fn, "w") as f: + f.write(fn) + + # defaults + vfs = AuthSrv(Namespace(c=None, a=[], v=[]), None).vfs + self.assertEqual(vfs.nodes, {}) + self.assertEqual(vfs.vpath, "") + self.assertEqual(vfs.realpath, td) + self.assertEqual(vfs.uread, ["*"]) + self.assertEqual(vfs.uwrite, ["*"]) + + # single read-only rootfs (relative path) + vfs = AuthSrv(Namespace(c=None, a=[], v=["a/ab/::r"]), None).vfs + self.assertEqual(vfs.nodes, {}) + self.assertEqual(vfs.vpath, "") + self.assertEqual(vfs.realpath, td + "/a/ab") + self.assertEqual(vfs.uread, ["*"]) + self.assertEqual(vfs.uwrite, []) + + # single read-only rootfs (absolute path) + vfs = AuthSrv(Namespace(c=None, a=[], v=[td + "//a/ac/../aa//::r"]), None).vfs + self.assertEqual(vfs.nodes, {}) + self.assertEqual(vfs.vpath, "") + self.assertEqual(vfs.realpath, td + "/a/aa") + self.assertEqual(vfs.uread, ["*"]) + self.assertEqual(vfs.uwrite, []) + + # read-only rootfs with write-only subdirectory + vfs = AuthSrv( + Namespace(c=None, a=[], v=[".::r", "a/ac/acb:a/ac/acb:w"]), None + ).vfs + self.assertEqual(len(vfs.nodes), 1) + self.assertEqual(vfs.vpath, "") + self.assertEqual(vfs.realpath, td) + self.assertEqual(vfs.uread, ["*"]) + self.assertEqual(vfs.uwrite, []) + n = vfs.nodes["a"] + self.assertEqual(len(vfs.nodes), 1) + self.assertEqual(n.vpath, "a") + self.assertEqual(n.realpath, td + "/a") + self.assertEqual(n.uread, ["*"]) + self.assertEqual(n.uwrite, []) + n = n.nodes["ac"] + self.assertEqual(len(vfs.nodes), 1) + self.assertEqual(n.vpath, "a/ac") + self.assertEqual(n.realpath, td + "/a/ac") + self.assertEqual(n.uread, ["*"]) + self.assertEqual(n.uwrite, []) + n = n.nodes["acb"] + self.assertEqual(n.nodes, {}) + self.assertEqual(n.vpath, "a/ac/acb") + self.assertEqual(n.realpath, td + "/a/ac/acb") + self.assertEqual(n.uread, []) + self.assertEqual(n.uwrite, ["*"]) + + # breadth-first construction + vfs = AuthSrv( + Namespace( + c=None, + a=[], + v=[ + "a/ac/acb:a/ac/acb:w", + "a:a:w", + ".::r", + "abacdfasdq:abacdfasdq:w", + "a/ac:a/ac:w", + ], + ), + None, + ).vfs + + # shadowing + # crossreferences + # loops + # listdir mapping + # access reduction + + shutil.rmtree(td)