diff --git a/README.md b/README.md index 9629b1b..091b2d2 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ Run directly from repository with Hatch (or use pip install as usual): ```sh -hatch run sanic cista --reload --dev +hatch run cista -l :3000 /path/to/files ``` -Configuration file is created `.cista.toml` in current directory, which is also shared by default. Edit while the server is not running to set share path and other parameters. - -No authentication yet, so implement access control externally or be careful with your files. +Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script). diff --git a/cista/__init__.py b/cista/__init__.py old mode 100644 new mode 100755 index c07c459..e69de29 --- a/cista/__init__.py +++ b/cista/__init__.py @@ -1 +0,0 @@ -from .app import app diff --git a/cista/__main__.py b/cista/__main__.py new file mode 100755 index 0000000..5042baa --- /dev/null +++ b/cista/__main__.py @@ -0,0 +1,92 @@ +import re +import sys +from pathlib import Path + +from docopt import docopt + +from . import app, config, droppy, serve +from ._version import version + +app # Needed for Sanic multiprocessing + +doc = f"""Cista {version} - A file storage for the web. + +Usage: + cista [-c ] [-l ] [--import-droppy] [--dev] [] + +Options: + -c CONFDIR Custom config directory + -l LISTEN-ADDR Listen on + :8000 (localhost port, plain http) + :3000 (bind another address, port) + /path/to/unix.sock (unix socket) + example.com (run on 80 and 443 with LetsEncrypt) + --import-droppy Import Droppy config from ~/.droppy/config + --dev Developer mode (reloads, friendlier crashes, more logs) + +Listen address, path and imported options are preserved in config, and only +custom config dir and dev mode need to be specified on subsequent runs. +""" + +def main(): + # Dev mode doesn't catch exceptions + if "--dev" in sys.argv: + return _main() + # Normal mode keeps it quiet + try: + return _main() + except Exception as e: + print("Error:", e) + return 1 + +def _main(): + args = docopt(doc) + listen = args["-l"] + # Validate arguments first + if args[""]: + path = Path(args[""]) + if not path.is_dir(): + raise ValueError(f"No such directory: {path}") + else: + path = None + if args["-c"]: + # Custom config directory + confdir = Path(args["-c"]).resolve() + if confdir.exists() and not confdir.is_dir(): + if confdir.name != config.conffile.name: + raise ValueError("Config path is not a directory") + # Accidentally pointed to the cista.toml, use parent + confdir = confdir.parent + config.conffile = config.conffile.with_parent(confdir) + exists = config.conffile.exists() + import_droppy = args["--import-droppy"] + necessary_opts = exists or import_droppy or path and listen + if not necessary_opts: + # Maybe run without arguments + print(doc) + print("No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n") + return 1 + settings = {} + if import_droppy: + if exists: + raise ValueError(f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}") + settings = droppy.readconf() + if path: settings["path"] = path + if listen: settings["listen"] = listen + operation = config.update_config(settings) + print(f"Config {operation}: {config.conffile}") + # Prepare to serve + domain = unix = port = None + url, _ = serve.parse_listen(config.config.listen) + if not config.config.path.is_dir(): + raise ValueError(f"No such directory: {config.config.path}") + extra = f" ({unix})" if unix else "" + dev = args["--dev"] + if dev: + extra += " (dev mode)" + print(f"Serving {config.config.path} at {url}{extra}") + # Run the server + serve.run(dev=dev) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cista/app.py b/cista/app.py old mode 100644 new mode 100755 index 9217c43..fa66168 --- a/cista/app.py +++ b/cista/app.py @@ -6,10 +6,9 @@ from sanic import Sanic, redirect from sanic.log import logger from sanic.response import html -from . import session, watching +from . import config, session, watching from .auth import authbp -from .config import config -from .fileio import ROOT, FileServer +from .fileio import FileServer from .protocol import ErrorMsg, FileRange, StatusMsg app = Sanic("cista") @@ -22,6 +21,9 @@ def asend(ws, msg): @app.before_server_start async def start_fileserver(app, _): + config.load_config() # Main process may have loaded it but we haven't + app.static("/files", config.config.path, use_content_range=True, stream_large_files=True, directory_view=True) + await fileserver.start() @app.after_server_stop @@ -30,8 +32,7 @@ async def stop_fileserver(app, _): @app.get("/") async def index_page(request): - s = config.public or session.get(request) - print("Main session", s) + s = config.config.public or session.get(request) if not s: return redirect("/login") index = files("cista").joinpath("static", "index.html").read_text() @@ -40,8 +41,6 @@ async def index_page(request): index += str(E.div(flash, id="flash")) return html(index) -app.static("/files", ROOT, use_content_range=True, stream_large_files=True, directory_view=True) - @app.websocket('/api/upload') async def upload(request, ws): alink = fileserver.alink diff --git a/cista/asynclink.py b/cista/asynclink.py old mode 100644 new mode 100755 diff --git a/cista/auth.py b/cista/auth.py old mode 100644 new mode 100755 index 691be43..21b5537 --- a/cista/auth.py +++ b/cista/auth.py @@ -8,8 +8,7 @@ import msgspec from html5tagger import Document from sanic import Blueprint, html, json, redirect -from . import session -from .config import User, config +from . import config, session _argon = argon2.PasswordHasher() _droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$') @@ -21,7 +20,7 @@ def login(username: str, password: str): un = _pwnorm(username) pw = _pwnorm(password) try: - u = config.users[un.decode()] + u = config.config.users[un.decode()] except KeyError: raise ValueError("Invalid username") # Verify password @@ -44,12 +43,12 @@ def login(username: str, password: str): need_rehash = True # Login successful if need_rehash: - u.set_password(password) + set_password(u, password) now = int(time()) u.lastSeen = now return u -def set_password(user: User, password: str): +def set_password(user: config.User, password: str): user.hash = _argon.hash(_pwnorm(password)) class LoginResponse(msgspec.Struct): @@ -90,7 +89,6 @@ async def login_post(request): username = request.json["username"] password = request.json["password"] else: - print(request.form) username = request.form["username"][0] password = request.form["password"][0] if not username or not password: @@ -105,7 +103,7 @@ async def login_post(request): }) else: res = redirect("/") - res.cookies.add_cookie("flash", "Logged in", host_prefix=True, max_age=5) + res.cookies.add_cookie("flash", "Logged in", host_prefix=True, max_age=5) session.create(res, username) return res @@ -113,5 +111,5 @@ async def login_post(request): async def logout_post(request): res = redirect("/") session.delete(res) - res.cookies.add_cookie("flash", "Logged out",host_prefix=True, max_age=5) + res.cookies.add_cookie("flash", "Logged out", host_prefix=True, max_age=5) return res diff --git a/cista/config.py b/cista/config.py old mode 100644 new mode 100755 index 5a944ac..3429c90 --- a/cista/config.py +++ b/cista/config.py @@ -10,7 +10,8 @@ import msgspec class Config(msgspec.Struct): - path: Path = Path.cwd() + path: Path + listen: str secret: str = secrets.token_hex(12) public: bool = False users: dict[str, User] = {} @@ -26,7 +27,8 @@ class Link(msgspec.Struct, omit_defaults=True): creator: str = "" expires: int = 0 -config = Config() +config = None +conffile = Path.home() / ".local/share/cista/db.toml" def derived_secret(*params, len=8) -> bytes: """Used to derive secret keys from the main secret""" @@ -51,10 +53,10 @@ def dec_hook(typ, obj): return Path(obj) raise TypeError -conffile = Path.cwd() / ".cista.toml" - def config_update(modify): global config + if not conffile.exists(): + conffile.parent.mkdir(parents=True, exist_ok=True) tmpname = conffile.with_suffix(".tmp") try: f = tmpname.open("xb") @@ -68,8 +70,12 @@ def config_update(modify): old = conffile.read_bytes() c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook) except FileNotFoundError: + # No existing config file, make sure we have a folder... + confdir = conffile.parent + confdir.mkdir(parents=True, exist_ok=True) + confdir.chmod(0o700) old = b"" - c = Config() # Initialize with defaults + c = None c = modify(c) new = msgspec.toml.encode(c, enc_hook=enc_hook) if old == new: @@ -98,12 +104,14 @@ def modifies_config(modify): return c return wrapper -@modifies_config -def droppy_import(config: Config) -> Config: - p = Path.home() / ".droppy/config" - cf = msgspec.json.decode((p / "config.json").read_bytes()) - db = msgspec.json.decode((p / "db.json").read_bytes()) - return msgspec.convert(cf | db, Config) +def load_config(): + global config + config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) -# Load/initialize config file -print(conffile, config_update(lambda c: c)) +@modifies_config +def update_config(config: Config, changes: dict) -> Config: + """Create/update the config with new values, respecting changes done by others.""" + # Encode into dict, update values with new, convert to Config + settings = {} if config is None else msgspec.to_builtins(config, enc_hook=enc_hook) + settings.update(changes) + return msgspec.convert(settings, Config, dec_hook=dec_hook) diff --git a/cista/droppy.py b/cista/droppy.py new file mode 100755 index 0000000..9e2ccf3 --- /dev/null +++ b/cista/droppy.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import msgspec + + +def readconf() -> dict: + p = Path.home() / ".droppy/config" # Hardcoded in Droppy + cf = msgspec.json.decode((p / "config.json").read_bytes()) + db = msgspec.json.decode((p / "db.json").read_bytes()) + cf["path"] = p.parent / "files" + cf["listen"] = _droppy_listeners(cf) + return cf | db + +def _droppy_listeners(cf): + """Convert Droppy listeners to our format, for typical cases but not in full.""" + for listener in cf["listeners"]: + try: + if listener["protocol"] == "https": + # TODO: Add support for TLS + continue + socket = listener.get("socket") + if socket: + if isinstance(socket, list): socket = socket[0] + return f"{socket}" + port = listener["port"] + if isinstance(port, list): port = port[0] + host = listener["host"] + if isinstance(host, list): host = host[0] + if host in ("127.0.0.1", "::", "localhost"): return f":{port}" + return f"{host}:{port}" + except (KeyError, IndexError): + continue + # If none matched, fallback to Droppy default + return f"0.0.0.0:8989" diff --git a/cista/fileio.py b/cista/fileio.py old mode 100644 new mode 100755 index 9f6e050..e225d82 --- a/cista/fileio.py +++ b/cista/fileio.py @@ -9,8 +9,6 @@ from . import config from .asynclink import AsyncLink from .lrucache import LRUCache -ROOT = config.config.path -print("Serving", ROOT) def fuid(stat) -> str: """Unique file ID. Stays the same on renames and modification.""" @@ -26,7 +24,7 @@ def sanitize_filename(filename: str) -> str: class File: def __init__(self, filename): - self.path = ROOT / filename + self.path = config.config.path / filename self.fd = None self.writable = False diff --git a/cista/httpredir.py b/cista/httpredir.py new file mode 100644 index 0000000..2f1bf50 --- /dev/null +++ b/cista/httpredir.py @@ -0,0 +1,21 @@ +from sanic import Sanic, exceptions, response + +app = Sanic("http_redirect") + +# Send all HTTP users to HTTPS +@app.exception(exceptions.NotFound, exceptions.MethodNotSupported) +def redirect_everything_else(request, exception): + server, path = request.server_name, request.path + if server and path.startswith("/"): + return response.redirect(f"https://{server}{path}", status=308) + return response.text("Bad Request. Please use HTTPS!", status=400) + +# ACME challenge for LetsEncrypt +app.get("/.well-known/acme-challenge/") +async def letsencrypt(request, challenge): + try: + return response.text(acme_challenges[challenge]) + except KeyError: + return response.text(f"ACME challenge not found: {challenge}", status=404) + +acme_challenges = {} diff --git a/cista/lrucache.py b/cista/lrucache.py old mode 100644 new mode 100755 diff --git a/cista/protocol.py b/cista/protocol.py old mode 100644 new mode 100755 diff --git a/cista/serve.py b/cista/serve.py new file mode 100755 index 0000000..b20badf --- /dev/null +++ b/cista/serve.py @@ -0,0 +1,37 @@ +import os +import re +from pathlib import Path + +from sanic import Sanic + +from . import config + + +def run(dev=False): + """Run Sanic main process that spawns worker processes to serve HTTP requests.""" + from .app import app + url, opts = parse_listen(config.config.listen) + # Silence Sanic's warning about running in production rather than debug + os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" + if opts.get("ssl"): + # Run plain HTTP redirect/acme server on port 80 + from . import httpredir + httpredir.app.prepare(port=80, motd=False) + app.prepare(**opts, motd=False, dev=dev, auto_reload=dev) + Sanic.serve() + +def parse_listen(listen): + if listen.startswith("/"): + unix = Path(listen).resolve() + if not unix.parent.exists(): + raise ValueError(f"Directory for unix socket does not exist: {unix.parent}/") + return "http://localhost", {"unix": unix} + elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE): + return f"https://{listen}", {"host": listen, "ssl": True} + else: + try: + addr, _port = listen.split(":", 1) + port = int(_port) + except Exception: + raise ValueError(f"Invalid listen address: {listen}") + return f"http://localhost:{port}", {"host": addr, "port": port} diff --git a/cista/session.py b/cista/session.py old mode 100644 new mode 100755 index 3cc9d4f..a6000fc --- a/cista/session.py +++ b/cista/session.py @@ -4,12 +4,12 @@ import jwt from .config import derived_secret -session_secret = derived_secret("session") +session_secret = lambda: derived_secret("session") max_age = 60 # Seconds since last login def get(request): try: - return jwt.decode(request.cookies.s, session_secret, algorithms=["HS256"]) + return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) except Exception as e: s = None return False if "s" in request.cookies else None @@ -20,12 +20,12 @@ def create(res, username, **kwargs): "username": username, **kwargs, } - s = jwt.encode(data, session_secret) + s = jwt.encode(data, session_secret()) res.cookies.add_cookie("s", s, host_prefix=True, httponly=True, max_age=max_age) def update(res, s, **kwargs): s.update(kwargs) - s = jwt.encode(s, session_secret) + s = jwt.encode(s, session_secret()) res.cookies.add_cookie("s", s, host_prefix=True, httponly=True, max_age=max(1, s["exp"] - int(time()))) def delete(res): diff --git a/cista/static/index.html b/cista/static/index.html old mode 100644 new mode 100755 diff --git a/cista/watching.py b/cista/watching.py old mode 100644 new mode 100755 index 896f8b3..5e7a549 --- a/cista/watching.py +++ b/cista/watching.py @@ -8,13 +8,12 @@ from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from . import config -from .fileio import ROOT from .protocol import DirEntry, FileEntry, UpdateEntry secret = secrets.token_bytes(8) pubsub = {} -def walk(path: Path = ROOT) -> DirEntry | FileEntry | None: +def walk(path: Path) -> DirEntry | FileEntry | None: try: s = path.stat() mtime = int(s.st_mtime) @@ -34,8 +33,9 @@ def walk(path: Path = ROOT) -> DirEntry | FileEntry | None: print("OS error walking path", path, e) return None -tree = {"": walk()} +tree = None tree_lock = threading.Lock() +rootpath = None def refresh(): root = tree[""] @@ -44,7 +44,7 @@ def refresh(): ]}).decode() def update(relpath: PurePosixPath, loop): - new = walk(ROOT / relpath) + new = walk(rootpath / relpath) with tree_lock: msg = update_internal(relpath, new) print(msg) @@ -102,11 +102,16 @@ async def broadcast(msg): def register(app, url): @app.before_server_start async def start_watcher(app, loop): + global tree, rootpath + config.load_config() + rootpath = config.config.path + # Pseudo nameless root entry to ease updates + tree = {"": walk(rootpath)} class Handler(FileSystemEventHandler): def on_any_event(self, event): - update(Path(event.src_path).relative_to(ROOT), loop) + update(Path(event.src_path).relative_to(rootpath), loop) app.ctx.observer = Observer() - app.ctx.observer.schedule(Handler(), str(ROOT), recursive=True) + app.ctx.observer.schedule(Handler(), str(rootpath), recursive=True) app.ctx.observer.start() @app.after_server_stop diff --git a/pyproject.toml b/pyproject.toml index 0fe66c8..2927be2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,13 @@ description = "Dropbox-like file server with modern web interface" readme = "README.md" license = "" authors = [ - { name = "Vasanko lda" }, + { name = "Vasanko" }, ] classifiers = [ ] dependencies = [ "argon2-cffi", + "docopt", "msgspec", "pathvalidate", "pyjwt", @@ -26,8 +27,11 @@ dependencies = [ [project.urls] Homepage = "" -[tool.hatch] -version.source = "vcs" +[project.scripts] +cista = "cista.__main__:main" + +[tool.hatch.version] +source = "vcs" [tool.hatch.build] hooks.vcs.version-file = "cista/_version.py" diff --git a/tests/test_lrucache.py b/tests/test_lrucache.py index 302e3a5..40ad70c 100644 --- a/tests/test_lrucache.py +++ b/tests/test_lrucache.py @@ -1,8 +1,11 @@ -import pytest -from unittest.mock import Mock from time import sleep +from unittest.mock import Mock + +import pytest + from cista.lrucache import LRUCache # Replace with actual import + def mock_open(key): mock = Mock() mock.close = Mock()