Refactor with its own entry point and startup script cista, instead of running via sanic. Config file handling and Droppy updates. HTTP redirection/acme server added.

This commit is contained in:
Leo Vasanko 2023-10-19 02:06:14 +03:00 committed by Leo Vasanko
parent 429a7dfb16
commit 05c6f03d20
18 changed files with 247 additions and 51 deletions

View File

@ -2,9 +2,7 @@
Run directly from repository with Hatch (or use pip install as usual): Run directly from repository with Hatch (or use pip install as usual):
```sh ```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. 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).
No authentication yet, so implement access control externally or be careful with your files.

1
cista/__init__.py Normal file → Executable file
View File

@ -1 +0,0 @@
from .app import app

92
cista/__main__.py Executable file
View File

@ -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 <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
Options:
-c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on
:8000 (localhost port, plain http)
<addr>: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 = Path(args["<path>"])
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())

13
cista/app.py Normal file → Executable file
View File

@ -6,10 +6,9 @@ from sanic import Sanic, redirect
from sanic.log import logger from sanic.log import logger
from sanic.response import html from sanic.response import html
from . import session, watching from . import config, session, watching
from .auth import authbp from .auth import authbp
from .config import config from .fileio import FileServer
from .fileio import ROOT, FileServer
from .protocol import ErrorMsg, FileRange, StatusMsg from .protocol import ErrorMsg, FileRange, StatusMsg
app = Sanic("cista") app = Sanic("cista")
@ -22,6 +21,9 @@ def asend(ws, msg):
@app.before_server_start @app.before_server_start
async def start_fileserver(app, _): 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() await fileserver.start()
@app.after_server_stop @app.after_server_stop
@ -30,8 +32,7 @@ async def stop_fileserver(app, _):
@app.get("/") @app.get("/")
async def index_page(request): async def index_page(request):
s = config.public or session.get(request) s = config.config.public or session.get(request)
print("Main session", s)
if not s: if not s:
return redirect("/login") return redirect("/login")
index = files("cista").joinpath("static", "index.html").read_text() 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")) index += str(E.div(flash, id="flash"))
return html(index) return html(index)
app.static("/files", ROOT, use_content_range=True, stream_large_files=True, directory_view=True)
@app.websocket('/api/upload') @app.websocket('/api/upload')
async def upload(request, ws): async def upload(request, ws):
alink = fileserver.alink alink = fileserver.alink

0
cista/asynclink.py Normal file → Executable file
View File

14
cista/auth.py Normal file → Executable file
View File

@ -8,8 +8,7 @@ import msgspec
from html5tagger import Document from html5tagger import Document
from sanic import Blueprint, html, json, redirect from sanic import Blueprint, html, json, redirect
from . import session from . import config, session
from .config import User, config
_argon = argon2.PasswordHasher() _argon = argon2.PasswordHasher()
_droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$') _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) un = _pwnorm(username)
pw = _pwnorm(password) pw = _pwnorm(password)
try: try:
u = config.users[un.decode()] u = config.config.users[un.decode()]
except KeyError: except KeyError:
raise ValueError("Invalid username") raise ValueError("Invalid username")
# Verify password # Verify password
@ -44,12 +43,12 @@ def login(username: str, password: str):
need_rehash = True need_rehash = True
# Login successful # Login successful
if need_rehash: if need_rehash:
u.set_password(password) set_password(u, password)
now = int(time()) now = int(time())
u.lastSeen = now u.lastSeen = now
return u return u
def set_password(user: User, password: str): def set_password(user: config.User, password: str):
user.hash = _argon.hash(_pwnorm(password)) user.hash = _argon.hash(_pwnorm(password))
class LoginResponse(msgspec.Struct): class LoginResponse(msgspec.Struct):
@ -90,7 +89,6 @@ async def login_post(request):
username = request.json["username"] username = request.json["username"]
password = request.json["password"] password = request.json["password"]
else: else:
print(request.form)
username = request.form["username"][0] username = request.form["username"][0]
password = request.form["password"][0] password = request.form["password"][0]
if not username or not password: if not username or not password:
@ -105,7 +103,7 @@ async def login_post(request):
}) })
else: else:
res = redirect("/") 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) session.create(res, username)
return res return res
@ -113,5 +111,5 @@ async def login_post(request):
async def logout_post(request): async def logout_post(request):
res = redirect("/") res = redirect("/")
session.delete(res) 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 return res

34
cista/config.py Normal file → Executable file
View File

@ -10,7 +10,8 @@ import msgspec
class Config(msgspec.Struct): class Config(msgspec.Struct):
path: Path = Path.cwd() path: Path
listen: str
secret: str = secrets.token_hex(12) secret: str = secrets.token_hex(12)
public: bool = False public: bool = False
users: dict[str, User] = {} users: dict[str, User] = {}
@ -26,7 +27,8 @@ class Link(msgspec.Struct, omit_defaults=True):
creator: str = "" creator: str = ""
expires: int = 0 expires: int = 0
config = Config() config = None
conffile = Path.home() / ".local/share/cista/db.toml"
def derived_secret(*params, len=8) -> bytes: def derived_secret(*params, len=8) -> bytes:
"""Used to derive secret keys from the main secret""" """Used to derive secret keys from the main secret"""
@ -51,10 +53,10 @@ def dec_hook(typ, obj):
return Path(obj) return Path(obj)
raise TypeError raise TypeError
conffile = Path.cwd() / ".cista.toml"
def config_update(modify): def config_update(modify):
global config global config
if not conffile.exists():
conffile.parent.mkdir(parents=True, exist_ok=True)
tmpname = conffile.with_suffix(".tmp") tmpname = conffile.with_suffix(".tmp")
try: try:
f = tmpname.open("xb") f = tmpname.open("xb")
@ -68,8 +70,12 @@ def config_update(modify):
old = conffile.read_bytes() old = conffile.read_bytes()
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook) c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError: 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"" old = b""
c = Config() # Initialize with defaults c = None
c = modify(c) c = modify(c)
new = msgspec.toml.encode(c, enc_hook=enc_hook) new = msgspec.toml.encode(c, enc_hook=enc_hook)
if old == new: if old == new:
@ -98,12 +104,14 @@ def modifies_config(modify):
return c return c
return wrapper return wrapper
@modifies_config def load_config():
def droppy_import(config: Config) -> Config: global config
p = Path.home() / ".droppy/config" config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
cf = msgspec.json.decode((p / "config.json").read_bytes())
db = msgspec.json.decode((p / "db.json").read_bytes())
return msgspec.convert(cf | db, Config)
# Load/initialize config file @modifies_config
print(conffile, config_update(lambda c: c)) 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)

34
cista/droppy.py Executable file
View File

@ -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"

4
cista/fileio.py Normal file → Executable file
View File

@ -9,8 +9,6 @@ from . import config
from .asynclink import AsyncLink from .asynclink import AsyncLink
from .lrucache import LRUCache from .lrucache import LRUCache
ROOT = config.config.path
print("Serving", ROOT)
def fuid(stat) -> str: def fuid(stat) -> str:
"""Unique file ID. Stays the same on renames and modification.""" """Unique file ID. Stays the same on renames and modification."""
@ -26,7 +24,7 @@ def sanitize_filename(filename: str) -> str:
class File: class File:
def __init__(self, filename): def __init__(self, filename):
self.path = ROOT / filename self.path = config.config.path / filename
self.fd = None self.fd = None
self.writable = False self.writable = False

21
cista/httpredir.py Normal file
View File

@ -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/<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 = {}

0
cista/lrucache.py Normal file → Executable file
View File

0
cista/protocol.py Normal file → Executable file
View File

37
cista/serve.py Executable file
View File

@ -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}

8
cista/session.py Normal file → Executable file
View File

@ -4,12 +4,12 @@ import jwt
from .config import derived_secret from .config import derived_secret
session_secret = derived_secret("session") session_secret = lambda: derived_secret("session")
max_age = 60 # Seconds since last login max_age = 60 # Seconds since last login
def get(request): def get(request):
try: 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: except Exception as e:
s = None s = None
return False if "s" in request.cookies else None return False if "s" in request.cookies else None
@ -20,12 +20,12 @@ def create(res, username, **kwargs):
"username": username, "username": username,
**kwargs, **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) res.cookies.add_cookie("s", s, host_prefix=True, httponly=True, max_age=max_age)
def update(res, s, **kwargs): def update(res, s, **kwargs):
s.update(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()))) res.cookies.add_cookie("s", s, host_prefix=True, httponly=True, max_age=max(1, s["exp"] - int(time())))
def delete(res): def delete(res):

0
cista/static/index.html Normal file → Executable file
View File

17
cista/watching.py Normal file → Executable file
View File

@ -8,13 +8,12 @@ from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer from watchdog.observers import Observer
from . import config from . import config
from .fileio import ROOT
from .protocol import DirEntry, FileEntry, UpdateEntry from .protocol import DirEntry, FileEntry, UpdateEntry
secret = secrets.token_bytes(8) secret = secrets.token_bytes(8)
pubsub = {} pubsub = {}
def walk(path: Path = ROOT) -> DirEntry | FileEntry | None: def walk(path: Path) -> DirEntry | FileEntry | None:
try: try:
s = path.stat() s = path.stat()
mtime = int(s.st_mtime) mtime = int(s.st_mtime)
@ -34,8 +33,9 @@ def walk(path: Path = ROOT) -> DirEntry | FileEntry | None:
print("OS error walking path", path, e) print("OS error walking path", path, e)
return None return None
tree = {"": walk()} tree = None
tree_lock = threading.Lock() tree_lock = threading.Lock()
rootpath = None
def refresh(): def refresh():
root = tree[""] root = tree[""]
@ -44,7 +44,7 @@ def refresh():
]}).decode() ]}).decode()
def update(relpath: PurePosixPath, loop): def update(relpath: PurePosixPath, loop):
new = walk(ROOT / relpath) new = walk(rootpath / relpath)
with tree_lock: with tree_lock:
msg = update_internal(relpath, new) msg = update_internal(relpath, new)
print(msg) print(msg)
@ -102,11 +102,16 @@ async def broadcast(msg):
def register(app, url): def register(app, url):
@app.before_server_start @app.before_server_start
async def start_watcher(app, loop): 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): class Handler(FileSystemEventHandler):
def on_any_event(self, event): 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 = Observer()
app.ctx.observer.schedule(Handler(), str(ROOT), recursive=True) app.ctx.observer.schedule(Handler(), str(rootpath), recursive=True)
app.ctx.observer.start() app.ctx.observer.start()
@app.after_server_stop @app.after_server_stop

View File

@ -9,12 +9,13 @@ description = "Dropbox-like file server with modern web interface"
readme = "README.md" readme = "README.md"
license = "" license = ""
authors = [ authors = [
{ name = "Vasanko lda" }, { name = "Vasanko" },
] ]
classifiers = [ classifiers = [
] ]
dependencies = [ dependencies = [
"argon2-cffi", "argon2-cffi",
"docopt",
"msgspec", "msgspec",
"pathvalidate", "pathvalidate",
"pyjwt", "pyjwt",
@ -26,8 +27,11 @@ dependencies = [
[project.urls] [project.urls]
Homepage = "" Homepage = ""
[tool.hatch] [project.scripts]
version.source = "vcs" cista = "cista.__main__:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build] [tool.hatch.build]
hooks.vcs.version-file = "cista/_version.py" hooks.vcs.version-file = "cista/_version.py"

View File

@ -1,8 +1,11 @@
import pytest
from unittest.mock import Mock
from time import sleep from time import sleep
from unittest.mock import Mock
import pytest
from cista.lrucache import LRUCache # Replace with actual import from cista.lrucache import LRUCache # Replace with actual import
def mock_open(key): def mock_open(key):
mock = Mock() mock = Mock()
mock.close = Mock() mock.close = Mock()