Frontend created and rewritten a few times, with some backend fixes #1
0
cista/__init__.py
Executable file → Normal file
0
cista/__init__.py
Executable file → Normal file
5
cista/__main__.py
Executable file → Normal file
5
cista/__main__.py
Executable file → Normal 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):
|
||||||
|
|
|
@ -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
14
cista/app.py
Executable file → Normal 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
13
cista/auth.py
Executable file → Normal 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
2
cista/config.py
Executable file → Normal 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
6
cista/droppy.py
Executable file → Normal 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
4
cista/fileio.py
Executable file → Normal 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
5
cista/protocol.py
Executable file → Normal 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
21
cista/serve.py
Executable file → Normal 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
0
cista/session.py
Executable file → Normal 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
3
cista/util/asynclink.py
Executable file → Normal 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
2
cista/util/lrucache.py
Executable file → Normal 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
20
cista/watching.py
Executable file → Normal 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
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue
Block a user