This commit is contained in:
Leo Vasanko 2023-11-01 19:36:10 +00:00
parent 0d6180e8a4
commit 783af44e26
17 changed files with 94 additions and 53 deletions

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

5
cista/__main__.py Executable file → Normal file
View File

@ -67,14 +67,14 @@ def _main():
# Maybe run without arguments # Maybe run without arguments
print(doc) print(doc)
print( 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" "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 return 1
settings = {} settings = {}
if import_droppy: if import_droppy:
if exists: if exists:
raise ValueError( raise ValueError(
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}" f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
) )
settings = droppy.readconf() settings = droppy.readconf()
if path: if path:
@ -95,6 +95,7 @@ def _main():
print(f"Serving {config.config.path} at {url}{extra}") print(f"Serving {config.config.path} at {url}{extra}")
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0
def _confdir(args): def _confdir(args):

View File

@ -32,7 +32,7 @@ async def upload(req, ws):
text = await ws.recv() text = await ws.recv()
if not isinstance(text, str): if not isinstance(text, str):
raise ValueError( raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}" f"Expected JSON control, got binary len(data) = {len(text)}",
) )
req = msgspec.json.decode(text, type=FileRange) req = msgspec.json.decode(text, type=FileRange)
pos = req.start pos = req.start
@ -46,7 +46,6 @@ async def upload(req, ws):
# Report success # Report success
res = StatusMsg(status="ack", req=req) res = StatusMsg(status="ack", req=req)
await asend(ws, res) await asend(ws, res)
# await ws.drain()
@bp.websocket("download") @bp.websocket("download")
@ -58,7 +57,7 @@ async def download(req, ws):
text = await ws.recv() text = await ws.recv()
if not isinstance(text, str): if not isinstance(text, str):
raise ValueError( raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}" f"Expected JSON control, got binary len(data) = {len(text)}",
) )
req = msgspec.json.decode(text, type=FileRange) req = msgspec.json.decode(text, type=FileRange)
pos = req.start pos = req.start
@ -70,7 +69,6 @@ async def download(req, ws):
# Report success # Report success
res = StatusMsg(status="ack", req=req) res = StatusMsg(status="ack", req=req)
await asend(ws, res) await asend(ws, res)
# await ws.drain()
@bp.websocket("control") @bp.websocket("control")

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

@ -1,15 +1,15 @@
import asyncio
import mimetypes import mimetypes
from importlib.resources import files from importlib.resources import files
from urllib.parse import unquote from urllib.parse import unquote
import sanic.helpers
import asyncio
import brotli
from blake3 import blake3
from sanic import Blueprint, Sanic, raw, empty
from sanic.exceptions import Forbidden, NotFound
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw
from sanic.exceptions import Forbidden, NotFound
from cista import auth, config, session, watching from cista import auth, config, session, watching
from cista.api import bp from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception from cista.util.apphelpers import handle_sanic_exception

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

@ -25,7 +25,7 @@ def login(username: str, password: str):
try: try:
u = config.config.users[un.decode()] u = config.config.users[un.decode()]
except KeyError: except KeyError:
raise ValueError("Invalid username") raise ValueError("Invalid username") from None
# Verify password # Verify password
need_rehash = False need_rehash = False
if not u.hash: if not u.hash:
@ -41,7 +41,7 @@ def login(username: str, password: str):
try: try:
_argon.verify(u.hash, pw) _argon.verify(u.hash, pw)
except Exception: except Exception:
raise ValueError("Invalid password") raise ValueError("Invalid password") from None
if _argon.check_needs_rehash(u.hash): if _argon.check_needs_rehash(u.hash):
need_rehash = True need_rehash = True
# Login successful # Login successful
@ -62,7 +62,7 @@ class LoginResponse(msgspec.Struct):
error: str = "" error: str = ""
def verify(request, privileged=False): def verify(request, *, privileged=False):
"""Raise Unauthorized or Forbidden if the request is not authorized""" """Raise Unauthorized or Forbidden if the request is not authorized"""
if privileged: if privileged:
if request.ctx.user: if request.ctx.user:
@ -130,11 +130,14 @@ async def login_post(request):
if not username or not password: if not username or not password:
raise KeyError raise KeyError
except KeyError: except KeyError:
raise BadRequest("Missing username or password", context={"redirect": "/login"}) raise BadRequest(
"Missing username or password",
context={"redirect": "/login"},
) from None
try: try:
user = login(username, password) user = login(username, password)
except ValueError as e: except ValueError as e:
raise Forbidden(str(e), context={"redirect": "/login"}) raise Forbidden(str(e), context={"redirect": "/login"}) from e
if "text/html" in request.headers.accept: if "text/html" in request.headers.accept:
res = redirect("/") res = redirect("/")

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

@ -21,7 +21,7 @@ class Config(msgspec.Struct):
class User(msgspec.Struct, omit_defaults=True): class User(msgspec.Struct, omit_defaults=True):
privileged: bool = False privileged: bool = False
hash: str = "" hash: str = ""
lastSeen: int = 0 lastSeen: int = 0 # noqa: N815
class Link(msgspec.Struct, omit_defaults=True): class Link(msgspec.Struct, omit_defaults=True):

6
cista/droppy.py Executable file → Normal file
View File

@ -30,10 +30,12 @@ def _droppy_listeners(cf):
host = listener["host"] host = listener["host"]
if isinstance(host, list): if isinstance(host, list):
host = host[0] host = host[0]
except (KeyError, IndexError):
continue
else:
if host in ("127.0.0.1", "::", "localhost"): if host in ("127.0.0.1", "::", "localhost"):
return f":{port}" return f":{port}"
return f"{host}:{port}" return f"{host}:{port}"
except (KeyError, IndexError):
continue
# If none matched, fallback to Droppy default # If none matched, fallback to Droppy default
return "0.0.0.0:8989" return "0.0.0.0:8989"

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

@ -62,7 +62,9 @@ class FileServer:
async def start(self): async def start(self):
self.alink = AsyncLink() self.alink = AsyncLink()
self.worker = asyncio.get_event_loop().run_in_executor( self.worker = asyncio.get_event_loop().run_in_executor(
None, self.worker_thread, self.alink.to_sync None,
self.worker_thread,
self.alink.to_sync,
) )
self.cache = LRUCache(File, capacity=10, maxage=5.0) self.cache = LRUCache(File, capacity=10, maxage=5.0)

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

@ -74,7 +74,10 @@ class Cp(ControlBase):
for p in sel: for p in sel:
# Note: copies as dst rather than in dst unless name is appended. # Note: copies as dst rather than in dst unless name is appended.
shutil.copytree( shutil.copytree(
p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True p,
dst / p.name,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
) )

21
cista/serve.py Executable file → Normal file
View File

@ -7,7 +7,7 @@ from sanic import Sanic
from cista import config, server80 from cista import config, server80
def run(dev=False): def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app from .app import app
@ -38,7 +38,7 @@ def check_cert(certdir, domain):
return return
# TODO: Use certbot to fetch a cert # TODO: Use certbot to fetch a cert
raise ValueError( raise ValueError(
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}" f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
) )
@ -47,15 +47,14 @@ def parse_listen(listen):
unix = Path(listen).resolve() unix = Path(listen).resolve()
if not unix.parent.exists(): if not unix.parent.exists():
raise ValueError( raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/" f"Directory for unix socket does not exist: {unix.parent}/",
) )
return "http://localhost", {"unix": unix} return "http://localhost", {"unix": unix}
elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE): if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True} return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
else: try:
try: addr, _port = listen.split(":", 1)
addr, _port = listen.split(":", 1) port = int(_port)
port = int(_port) except Exception:
except Exception: raise ValueError(f"Invalid listen address: {listen}") from None
raise ValueError(f"Invalid listen address: {listen}") return f"http://localhost:{port}", {"host": addr, "port": port}
return f"http://localhost:{port}", {"host": addr, "port": port}

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

View File

@ -33,7 +33,8 @@ async def handle_sanic_exception(request, e):
# Non-browsers get JSON errors # Non-browsers get JSON errors
if "text/html" not in request.headers.accept: if "text/html" not in request.headers.accept:
return jres( return jres(
ErrorMsg({"code": code, "message": message, **context}), status=code ErrorMsg({"code": code, "message": message, **context}),
status=code,
) )
# Redirections flash the error message via cookies # Redirections flash the error message via cookies
if "redirect" in context: if "redirect" in context:

3
cista/util/asynclink.py Executable file → Normal file
View File

@ -80,8 +80,9 @@ class SyncRequest:
if exc: if exc:
self.set_exception(exc) self.set_exception(exc)
return True return True
elif not self.done: if not self.done:
self.set_result(None) self.set_result(None)
return None
def set_result(self, value): def set_result(self, value):
"""Set result value; mark as done.""" """Set result value; mark as done."""

2
cista/util/lrucache.py Executable file → Normal file
View File

@ -41,7 +41,7 @@ class LRUCache:
The corresponding item's handle. The corresponding item's handle.
""" """
# Take from cache or open a new one # Take from cache or open a new one
for i, (k, f, ts) in enumerate(self.cache): for i, (k, f, _ts) in enumerate(self.cache): # noqa: B007
if k == key: if k == key:
self.cache.pop(i) self.cache.pop(i)
break break

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

@ -80,8 +80,8 @@ def format_du():
"used": disk_usage.used, "used": disk_usage.used,
"free": disk_usage.free, "free": disk_usage.free,
"storage": tree[""].size, "storage": tree[""].size,
} },
} },
).decode() ).decode()
@ -90,9 +90,9 @@ def format_tree():
return msgspec.json.encode( return msgspec.json.encode(
{ {
"update": [ "update": [
UpdateEntry(id=root.id, size=root.size, mtime=root.mtime, dir=root.dir) UpdateEntry(id=root.id, size=root.size, mtime=root.mtime, dir=root.dir),
] ],
} },
).decode() ).decode()
@ -112,7 +112,7 @@ def walk(path: Path) -> DirEntry | FileEntry | None:
} }
if tree: if tree:
size = sum(v.size for v in tree.values()) size = sum(v.size for v in tree.values())
mtime = max(mtime, max(v.mtime for v in tree.values())) mtime = max(mtime, *(v.mtime for v in tree.values()))
else: else:
size = 0 size = 0
return DirEntry(id_, size, mtime, tree) return DirEntry(id_, size, mtime, tree)
@ -135,7 +135,8 @@ def update(relpath: PurePosixPath, loop):
def update_internal( def update_internal(
relpath: PurePosixPath, new: DirEntry | FileEntry | None relpath: PurePosixPath,
new: DirEntry | FileEntry | None,
) -> list[UpdateEntry]: ) -> list[UpdateEntry]:
path = "", *relpath.parts path = "", *relpath.parts
old = tree old = tree
@ -181,9 +182,8 @@ def update_internal(
u.size = new.size u.size = new.size
if u.mtime != new.mtime: if u.mtime != new.mtime:
u.mtime = new.mtime u.mtime = new.mtime
if isinstance(new, DirEntry): if isinstance(new, DirEntry) and u.dir == new.dir:
if u.dir == new.dir: u.dir = new.dir
u.dir = new.dir
else: else:
del parent[name] del parent[name]
u.deleted = True u.deleted = True

View File

@ -7,12 +7,13 @@ name = "cista"
dynamic = ["version"] dynamic = ["version"]
description = "Dropbox-like file server with modern web interface" description = "Dropbox-like file server with modern web interface"
readme = "README.md" readme = "README.md"
license = "" license = "Public Domain"
authors = [ authors = [
{ name = "Vasanko" }, { name = "Vasanko" },
] ]
classifiers = [ classifiers = [
] ]
requires-python = ">=3.11"
dependencies = [ dependencies = [
"argon2-cffi", "argon2-cffi",
"blake3", "blake3",
@ -35,6 +36,7 @@ cista = "cista.__main__:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest", "pytest",
"ruff",
] ]
[tool.hatchling] [tool.hatchling]
@ -65,7 +67,36 @@ testpaths = [
"tests", "tests",
] ]
[tool.isort] [tool.ruff]
#src_paths = ["cista", "tests"] select = ["ALL"]
line_length = 120 ignore = [
multi_line_output = 5 "A0",
"ARG001",
"ANN",
"B018",
"BLE001",
"C901",
"COM812", # conflicts with ruff format
"D",
"E501",
"EM1",
"FIX002",
"ISC001", # conflicts with ruff format
"PGH003",
"PLR0912",
"PLR2004",
"PLW0603",
"S101",
"SLF001",
"T201",
"TD0",
"TRY",
]
show-source = true
show-fixes = true
[tool.ruff.isort]
known-first-party = ["cista"]
[tool.ruff.per-file-ignores]
"tests/*" = ["S", "ANN", "D", "INP"]

View File

@ -7,7 +7,7 @@ from cista import config
from cista.protocol import Cp, MkDir, Mv, Rename, Rm from cista.protocol import Cp, MkDir, Mv, Rename, Rm
@pytest.fixture @pytest.fixture()
def setup_temp_dir(): def setup_temp_dir():
with tempfile.TemporaryDirectory() as tmpdirname: with tempfile.TemporaryDirectory() as tmpdirname:
config.config = config.Config(path=Path(tmpdirname), listen=":0") config.config = config.Config(path=Path(tmpdirname), listen=":0")