Frontend created and rewritten a few times, with some backend fixes #1

Merged
leo merged 110 commits from plaintable into main 2023-11-08 20:38:40 +00:00
17 changed files with 94 additions and 53 deletions
Showing only changes of commit 783af44e26 - Show all commits

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

View File

@ -32,7 +32,7 @@ async def upload(req, ws):
text = await ws.recv()
if not isinstance(text, str):
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)
pos = req.start
@ -46,7 +46,6 @@ async def upload(req, ws):
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
# await ws.drain()
@bp.websocket("download")
@ -58,7 +57,7 @@ async def download(req, ws):
text = await ws.recv()
if not isinstance(text, str):
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)
pos = req.start
@ -70,7 +69,6 @@ async def download(req, ws):
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
# await ws.drain()
@bp.websocket("control")

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

@ -1,15 +1,15 @@
import asyncio
import mimetypes
from importlib.resources import files
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
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.api import bp
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:
u = config.config.users[un.decode()]
except KeyError:
raise ValueError("Invalid username")
raise ValueError("Invalid username") from None
# Verify password
need_rehash = False
if not u.hash:
@ -41,7 +41,7 @@ def login(username: str, password: str):
try:
_argon.verify(u.hash, pw)
except Exception:
raise ValueError("Invalid password")
raise ValueError("Invalid password") from None
if _argon.check_needs_rehash(u.hash):
need_rehash = True
# Login successful
@ -62,7 +62,7 @@ class LoginResponse(msgspec.Struct):
error: str = ""
def verify(request, privileged=False):
def verify(request, *, privileged=False):
"""Raise Unauthorized or Forbidden if the request is not authorized"""
if privileged:
if request.ctx.user:
@ -130,11 +130,14 @@ async def login_post(request):
if not username or not password:
raise KeyError
except KeyError:
raise BadRequest("Missing username or password", context={"redirect": "/login"})
raise BadRequest(
"Missing username or password",
context={"redirect": "/login"},
) from None
try:
user = login(username, password)
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:
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):
privileged: bool = False
hash: str = ""
lastSeen: int = 0
lastSeen: int = 0 # noqa: N815
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"]
if isinstance(host, list):
host = host[0]
except (KeyError, IndexError):
continue
else:
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 "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):
self.alink = AsyncLink()
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)

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

@ -74,7 +74,10 @@ class Cp(ControlBase):
for p in sel:
# Note: copies as dst rather than in dst unless name is appended.
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
def run(dev=False):
def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app
@ -38,7 +38,7 @@ def check_cert(certdir, domain):
return
# TODO: Use certbot to fetch a cert
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()
if not unix.parent.exists():
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}
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}
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}
try:
addr, _port = listen.split(":", 1)
port = int(_port)
except Exception:
raise ValueError(f"Invalid listen address: {listen}") from None
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
if "text/html" not in request.headers.accept:
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
if "redirect" in context:

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

@ -80,8 +80,9 @@ class SyncRequest:
if exc:
self.set_exception(exc)
return True
elif not self.done:
if not self.done:
self.set_result(None)
return None
def set_result(self, value):
"""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.
"""
# 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:
self.cache.pop(i)
break

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

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

View File

@ -7,12 +7,13 @@ name = "cista"
dynamic = ["version"]
description = "Dropbox-like file server with modern web interface"
readme = "README.md"
license = ""
license = "Public Domain"
authors = [
{ name = "Vasanko" },
]
classifiers = [
]
requires-python = ">=3.11"
dependencies = [
"argon2-cffi",
"blake3",
@ -35,6 +36,7 @@ cista = "cista.__main__:main"
[project.optional-dependencies]
dev = [
"pytest",
"ruff",
]
[tool.hatchling]
@ -65,7 +67,36 @@ testpaths = [
"tests",
]
[tool.isort]
#src_paths = ["cista", "tests"]
line_length = 120
multi_line_output = 5
[tool.ruff]
select = ["ALL"]
ignore = [
"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
@pytest.fixture
@pytest.fixture()
def setup_temp_dir():
with tempfile.TemporaryDirectory() as tmpdirname:
config.config = config.Config(path=Path(tmpdirname), listen=":0")