Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfc80d2462 | |||
|
|
bf604334bd | ||
|
|
3edf595983 | ||
|
|
6aef2e1208 | ||
|
|
091d57dba7 | ||
|
|
69a897cfec |
@@ -10,8 +10,24 @@ from cista.util import pwgen
|
|||||||
|
|
||||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||||
|
|
||||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
|
||||||
|
|
||||||
|
def create_banner():
|
||||||
|
"""Create a framed banner with the Cista version."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
subtitle = "A file storage for the web"
|
||||||
|
width = max(len(title), len(subtitle)) + 4
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
╭{"─" * width}╮
|
||||||
|
│{title:^{width}}│
|
||||||
|
│{subtitle:^{width}}│
|
||||||
|
╰{"─" * width}╯
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
banner = create_banner()
|
||||||
|
|
||||||
|
doc = """\
|
||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
@@ -35,6 +51,14 @@ User management:
|
|||||||
--password Reset password
|
--password Reset password
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
first_time_help = """\
|
||||||
|
No config file found! Get started with:
|
||||||
|
cista --user yourname --privileged # If you want user accounts
|
||||||
|
cista -l :8000 /path/to/files # Run the server on localhost:8000
|
||||||
|
|
||||||
|
See cista --help for other options!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Dev mode doesn't catch exceptions
|
# Dev mode doesn't catch exceptions
|
||||||
@@ -44,11 +68,19 @@ def main():
|
|||||||
try:
|
try:
|
||||||
return _main()
|
return _main()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error:", e)
|
sys.stderr.write(f"Error: {e}\n")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _main():
|
def _main():
|
||||||
|
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
|
||||||
|
if any(arg in sys.argv for arg in ("--help", "-h")):
|
||||||
|
sys.stdout.write(banner)
|
||||||
|
elif "--version" in sys.argv:
|
||||||
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
sys.stderr.write(banner)
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
@@ -62,18 +94,11 @@ def _main():
|
|||||||
path = None
|
path = None
|
||||||
_confdir(args)
|
_confdir(args)
|
||||||
exists = config.conffile.exists()
|
exists = config.conffile.exists()
|
||||||
print(config.conffile, exists)
|
|
||||||
import_droppy = args["--import-droppy"]
|
import_droppy = args["--import-droppy"]
|
||||||
necessary_opts = exists or import_droppy or path
|
necessary_opts = exists or import_droppy or path
|
||||||
if not necessary_opts:
|
if not necessary_opts:
|
||||||
# Maybe run without arguments
|
# Maybe run without arguments
|
||||||
print(doc)
|
sys.stderr.write(first_time_help)
|
||||||
print(
|
|
||||||
"No config file found! Get started with one of:\n"
|
|
||||||
" cista --user yourname --privileged\n"
|
|
||||||
" cista --import-droppy\n"
|
|
||||||
" cista -l :8000 /path/to/files\n"
|
|
||||||
)
|
|
||||||
return 1
|
return 1
|
||||||
settings = {}
|
settings = {}
|
||||||
if import_droppy:
|
if import_droppy:
|
||||||
@@ -94,7 +119,7 @@ def _main():
|
|||||||
# We have no users, so make it public
|
# We have no users, so make it public
|
||||||
settings["public"] = True
|
settings["public"] = True
|
||||||
operation = config.update_config(settings)
|
operation = config.update_config(settings)
|
||||||
print(f"Config {operation}: {config.conffile}")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
unix = None
|
unix = None
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
url, _ = serve.parse_listen(config.config.listen)
|
||||||
@@ -104,7 +129,7 @@ def _main():
|
|||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
if dev:
|
||||||
extra += " (dev mode)"
|
extra += " (dev mode)"
|
||||||
print(f"Serving {config.config.path} at {url}{extra}")
|
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
return 0
|
return 0
|
||||||
@@ -137,7 +162,7 @@ def _user(args):
|
|||||||
"public": False,
|
"public": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
print(f"Config {operation}: {config.conffile}\n")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
|
||||||
|
|
||||||
name = args["--user"]
|
name = args["--user"]
|
||||||
if not name or not name.isidentifier():
|
if not name or not name.isidentifier():
|
||||||
@@ -155,12 +180,12 @@ def _user(args):
|
|||||||
changes["password"] = pw = pwgen.generate()
|
changes["password"] = pw = pwgen.generate()
|
||||||
info += f"\n Password: {pw}\n"
|
info += f"\n Password: {pw}\n"
|
||||||
res = config.update_user(name, changes)
|
res = config.update_user(name, changes)
|
||||||
print(info)
|
sys.stderr.write(f"{info}\n")
|
||||||
if res == "read":
|
if res == "read":
|
||||||
print(" No changes")
|
sys.stderr.write(" No changes\n")
|
||||||
|
|
||||||
if operation == "created":
|
if operation == "created":
|
||||||
print(
|
sys.stderr.write(
|
||||||
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
27
cista/app.py
27
cista/app.py
@@ -9,7 +9,6 @@ from stat import S_IFDIR, S_IFREG
|
|||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
import brotli
|
|
||||||
import sanic.helpers
|
import sanic.helpers
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||||
@@ -17,6 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
|
|||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, watching
|
from cista import auth, config, preview, session, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
@@ -95,6 +95,7 @@ def _load_wwwroot(www):
|
|||||||
wwwnew = {}
|
wwwnew = {}
|
||||||
base = Path(__file__).with_name("wwwroot")
|
base = Path(__file__).with_name("wwwroot")
|
||||||
paths = [PurePath()]
|
paths = [PurePath()]
|
||||||
|
zstd = ZstdCompressor(level=10)
|
||||||
while paths:
|
while paths:
|
||||||
path = paths.pop(0)
|
path = paths.pop(0)
|
||||||
current = base / path
|
current = base / path
|
||||||
@@ -126,11 +127,11 @@ def _load_wwwroot(www):
|
|||||||
else "no-cache",
|
else "no-cache",
|
||||||
"content-type": mime,
|
"content-type": mime,
|
||||||
}
|
}
|
||||||
# Precompress with Brotli
|
# Precompress with ZSTD
|
||||||
br = brotli.compress(data)
|
zs = zstd.compress(data)
|
||||||
if len(br) >= len(data):
|
if len(zs) >= len(data):
|
||||||
br = False
|
zs = False
|
||||||
wwwnew[name] = data, br, headers
|
wwwnew[name] = data, zs, headers
|
||||||
if not wwwnew:
|
if not wwwnew:
|
||||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||||
if not www:
|
if not www:
|
||||||
@@ -182,9 +183,9 @@ async def refresh_wwwroot():
|
|||||||
for name in sorted(set(wwwold) - set(www)):
|
for name in sorted(set(wwwold) - set(www)):
|
||||||
changes += f"Deleted /{name}\n"
|
changes += f"Deleted /{name}\n"
|
||||||
if changes:
|
if changes:
|
||||||
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading wwwroot: {e!r}")
|
logger.error(f"Error loading wwwroot: {e!r}")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -196,14 +197,14 @@ async def wwwroot(req, path=""):
|
|||||||
name = unquote(path)
|
name = unquote(path)
|
||||||
if name not in www:
|
if name not in www:
|
||||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||||
data, br, headers = www[name]
|
data, zs, headers = www[name]
|
||||||
if req.headers.if_none_match == headers["etag"]:
|
if req.headers.if_none_match == headers["etag"]:
|
||||||
# The client has it cached, respond 304 Not Modified
|
# The client has it cached, respond 304 Not Modified
|
||||||
return empty(304, headers=headers)
|
return empty(304, headers=headers)
|
||||||
# Brotli compressed?
|
# Zstandard compressed?
|
||||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
if zs and "zstd" in req.headers.accept_encoding.split(", "):
|
||||||
headers = {**headers, "content-encoding": "br"}
|
headers = {**headers, "content-encoding": "zstd"}
|
||||||
data = br
|
data = zs
|
||||||
return raw(data, headers=headers)
|
return raw(data, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect
|
|||||||
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||||
|
|
||||||
from cista import config, session
|
from cista import config, session
|
||||||
|
from cista.util import pwgen
|
||||||
|
|
||||||
_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})$")
|
||||||
@@ -191,3 +192,91 @@ async def change_password(request):
|
|||||||
res = json({"message": "Password updated"})
|
res = json({"message": "Password updated"})
|
||||||
session.create(res, username)
|
session.create(res, username)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/users")
|
||||||
|
async def list_users(request):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
users = []
|
||||||
|
for name, user in config.config.users.items():
|
||||||
|
users.append(
|
||||||
|
{
|
||||||
|
"username": name,
|
||||||
|
"privileged": user.privileged,
|
||||||
|
"lastSeen": user.lastSeen,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json({"users": users})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/users")
|
||||||
|
async def create_user(request):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
if request.headers.content_type == "application/json":
|
||||||
|
username = request.json["username"]
|
||||||
|
password = request.json.get("password")
|
||||||
|
privileged = request.json.get("privileged", False)
|
||||||
|
else:
|
||||||
|
username = request.form["username"][0]
|
||||||
|
password = request.form.get("password", [None])[0]
|
||||||
|
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
|
||||||
|
if not username or not username.isidentifier():
|
||||||
|
raise ValueError("Invalid username")
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
if username in config.config.users:
|
||||||
|
raise BadRequest("User already exists")
|
||||||
|
if not password:
|
||||||
|
password = pwgen.generate()
|
||||||
|
changes = {"privileged": privileged}
|
||||||
|
changes["hash"] = _argon.hash(_pwnorm(password))
|
||||||
|
try:
|
||||||
|
config.update_user(username, changes)
|
||||||
|
except Exception as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
return json({"message": f"User {username} created", "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/users/<username>")
|
||||||
|
async def update_user(request, username):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
if request.headers.content_type == "application/json":
|
||||||
|
changes = request.json
|
||||||
|
else:
|
||||||
|
changes = {}
|
||||||
|
if "password" in request.form:
|
||||||
|
changes["password"] = request.form["password"][0]
|
||||||
|
if "privileged" in request.form:
|
||||||
|
changes["privileged"] = request.form["privileged"][0].lower() == "true"
|
||||||
|
except KeyError as e:
|
||||||
|
raise BadRequest("Missing fields") from e
|
||||||
|
password_response = None
|
||||||
|
if "password" in changes:
|
||||||
|
if changes["password"] == "":
|
||||||
|
changes["password"] = pwgen.generate()
|
||||||
|
password_response = changes["password"]
|
||||||
|
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
|
||||||
|
del changes["password"]
|
||||||
|
if not changes:
|
||||||
|
return json({"message": "No changes"})
|
||||||
|
try:
|
||||||
|
config.update_user(username, changes)
|
||||||
|
except Exception as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
response = {"message": f"User {username} updated"}
|
||||||
|
if password_response:
|
||||||
|
response["password"] = password_response
|
||||||
|
return json(response)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/config/public")
|
||||||
|
async def update_public(request):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
public = request.json["public"]
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing public field") from None
|
||||||
|
config.update_config({"public": public})
|
||||||
|
return json({"message": "Public setting updated"})
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from contextlib import suppress
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from time import time
|
from time import sleep, time
|
||||||
|
from typing import Callable, Concatenate, Literal, ParamSpec
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import msgspec.toml
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct):
|
||||||
@@ -22,6 +24,13 @@ class Config(msgspec.Struct):
|
|||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Typing: arguments for config-modifying functions
|
||||||
|
P = ParamSpec("P")
|
||||||
|
ResultStr = Literal["modified", "created", "read"]
|
||||||
|
RawModifyFunc = Callable[Concatenate[Config, P], Config]
|
||||||
|
ModifyPublic = Callable[P, ResultStr]
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, omit_defaults=True):
|
class User(msgspec.Struct, omit_defaults=True):
|
||||||
privileged: bool = False
|
privileged: bool = False
|
||||||
hash: str = ""
|
hash: str = ""
|
||||||
@@ -34,11 +43,13 @@ class Link(msgspec.Struct, omit_defaults=True):
|
|||||||
expires: int = 0
|
expires: int = 0
|
||||||
|
|
||||||
|
|
||||||
config = None
|
# Global variables - initialized during application startup
|
||||||
conffile = None
|
config: Config
|
||||||
|
conffile: Path
|
||||||
|
|
||||||
|
|
||||||
def init_confdir():
|
def init_confdir() -> None:
|
||||||
|
global conffile
|
||||||
if p := os.environ.get("CISTA_HOME"):
|
if p := os.environ.get("CISTA_HOME"):
|
||||||
home = Path(p)
|
home = Path(p)
|
||||||
else:
|
else:
|
||||||
@@ -49,8 +60,6 @@ def init_confdir():
|
|||||||
if not home.is_dir():
|
if not home.is_dir():
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
home.chmod(0o700)
|
home.chmod(0o700)
|
||||||
|
|
||||||
global conffile
|
|
||||||
conffile = home / "db.toml"
|
conffile = home / "db.toml"
|
||||||
|
|
||||||
|
|
||||||
@@ -77,10 +86,10 @@ def dec_hook(typ, obj):
|
|||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
|
|
||||||
def config_update(modify):
|
def config_update(
|
||||||
|
modify: RawModifyFunc,
|
||||||
|
) -> ResultStr | Literal["collision"]:
|
||||||
global config
|
global config
|
||||||
if conffile is None:
|
|
||||||
init_confdir()
|
|
||||||
tmpname = conffile.with_suffix(".tmp")
|
tmpname = conffile.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
f = tmpname.open("xb")
|
f = tmpname.open("xb")
|
||||||
@@ -95,7 +104,7 @@ def config_update(modify):
|
|||||||
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:
|
||||||
old = b""
|
old = b""
|
||||||
c = None
|
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
|
||||||
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:
|
||||||
@@ -118,17 +127,23 @@ def config_update(modify):
|
|||||||
return "modified" if old else "created"
|
return "modified" if old else "created"
|
||||||
|
|
||||||
|
|
||||||
def modifies_config(modify):
|
def modifies_config(
|
||||||
"""Decorator for functions that modify the config file"""
|
modify: Callable[Concatenate[Config, P], Config],
|
||||||
|
) -> Callable[P, ResultStr]:
|
||||||
|
"""Decorator for functions that modify the config file
|
||||||
|
|
||||||
|
The decorated function takes as first arg Config and returns it modified.
|
||||||
|
The wrapper handles atomic modification and returns a string indicating the result.
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(modify)
|
@wraps(modify)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
|
||||||
def m(c):
|
def m(c: Config) -> Config:
|
||||||
return modify(c, *args, **kwargs)
|
return modify(c, *args, **kwargs)
|
||||||
|
|
||||||
# Retry modification in case of write collision
|
# Retry modification in case of write collision
|
||||||
while (c := config_update(m)) == "collision":
|
while (c := config_update(m)) == "collision":
|
||||||
time.sleep(0.01)
|
sleep(0.01)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -136,8 +151,7 @@ def modifies_config(modify):
|
|||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
if conffile is None:
|
init_confdir()
|
||||||
init_confdir()
|
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +159,7 @@ def load_config():
|
|||||||
def update_config(conf: Config, changes: dict) -> Config:
|
def update_config(conf: Config, changes: dict) -> Config:
|
||||||
"""Create/update the config with new values, respecting changes done by others."""
|
"""Create/update the config with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings.update(changes)
|
settings.update(changes)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@@ -155,8 +169,13 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
"""Create/update a user with new values, respecting changes done by others."""
|
"""Create/update a user with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
try:
|
try:
|
||||||
u = conf.users[name].__copy__()
|
# Copy user by converting to dict and back
|
||||||
except (KeyError, AttributeError):
|
u = msgspec.convert(
|
||||||
|
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
|
||||||
|
User,
|
||||||
|
dec_hook=dec_hook,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
u = User()
|
u = User()
|
||||||
if "password" in changes:
|
if "password" in changes:
|
||||||
from . import auth
|
from . import auth
|
||||||
@@ -165,7 +184,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
del changes["password"]
|
del changes["password"]
|
||||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||||
udict.update(changes)
|
udict.update(changes)
|
||||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@@ -173,6 +192,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
@modifies_config
|
@modifies_config
|
||||||
def del_user(conf: Config, name: str) -> Config:
|
def del_user(conf: Config, name: str) -> Config:
|
||||||
"""Delete named user account."""
|
"""Delete named user account."""
|
||||||
ret = conf.__copy__()
|
# Create a copy by converting to dict and back
|
||||||
ret.users.pop(name)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
return ret
|
settings["users"].pop(name)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
class LRUCache:
|
class LRUCache:
|
||||||
@@ -12,7 +13,7 @@ class LRUCache:
|
|||||||
cache (list): Internal list storing the cache items.
|
cache (list): Internal list storing the cache items.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
def __init__(self, open: Callable, *, capacity: int, maxage: float):
|
||||||
"""
|
"""
|
||||||
Initialize LRUCache.
|
Initialize LRUCache.
|
||||||
|
|
||||||
@@ -50,7 +51,6 @@ class LRUCache:
|
|||||||
# Add/restore to end of cache
|
# Add/restore to end of cache
|
||||||
self.cache.insert(0, (key, f, monotonic()))
|
self.cache.insert(0, (key, f, monotonic()))
|
||||||
self.expire_items()
|
self.expire_items()
|
||||||
print(self.cache)
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
def expire_items(self):
|
def expire_items(self):
|
||||||
|
|||||||
6
frontend/env.d.ts
vendored
6
frontend/env.d.ts
vendored
@@ -1 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
|
<UserManagementModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<HeaderSelected :path="path.pathList" />
|
||||||
@@ -28,6 +29,7 @@ import { computed } from 'vue'
|
|||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ watchEffect(() => {
|
|||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
|
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||||
|
if (store.user.privileged) {
|
||||||
|
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||||
|
}
|
||||||
if (store.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
250
frontend/src/components/UserManagementModal.vue
Normal file
250
frontend/src/components/UserManagementModal.vue
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name=usermgmt title="Admin Settings">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<h3>Server Settings</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<input
|
||||||
|
id="publicServer"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="serverSettings.public"
|
||||||
|
@change="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<label for="publicServer">Publicly accessible without any user account.</label>
|
||||||
|
</div>
|
||||||
|
<h3>Users</h3>
|
||||||
|
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||||
|
<div v-if="success" class="success-message">
|
||||||
|
{{ success }}
|
||||||
|
<button @click="copySuccess" class="button small" title="Copy to clipboard"><EFBFBD></button>
|
||||||
|
</div>
|
||||||
|
<table class="user-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.username">
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="user.privileged"
|
||||||
|
@change="toggleAdmin(user, $event)"
|
||||||
|
:disabled="user.username === store.user.username"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button @click="renameUser(user)" class="button small" title="Rename user">✏️</button>
|
||||||
|
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
|
||||||
|
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="close" class="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
username: string
|
||||||
|
privileged: boolean
|
||||||
|
lastSeen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const serverSettings = reactive({
|
||||||
|
public: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const data = await listUsers()
|
||||||
|
users.value = data.users
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to load users'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addUser = async () => {
|
||||||
|
const username = window.prompt('Enter username for new user:')
|
||||||
|
if (!username || !username.trim()) return
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const result = await createUser(username.trim(), undefined, false)
|
||||||
|
await loadUsers()
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `User ${username.trim()} created. Password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to add user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAdmin = async (user: User, event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
await updateUser(user.username, { privileged: target.checked })
|
||||||
|
user.privileged = target.checked
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to update user'
|
||||||
|
target.checked = user.privileged // revert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameUser = async (user: User) => {
|
||||||
|
const newName = window.prompt('Enter new username:', user.username)
|
||||||
|
if (!newName || !newName.trim() || newName.trim() === user.username) return
|
||||||
|
// For rename, we need to create new user and delete old, or have a rename endpoint
|
||||||
|
// Since no rename endpoint, perhaps delete and create
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||||||
|
await deleteUser(user.username)
|
||||||
|
await loadUsers()
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to rename user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPassword = async (user: User) => {
|
||||||
|
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const result = await updateUser(user.username, { password: "" })
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to reset password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUserAction = async (username: string) => {
|
||||||
|
if (!confirm(`Delete user ${username}?`)) return
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
await deleteUser(username)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to delete user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copySuccess = async () => {
|
||||||
|
const passwordMatch = success.value.match(/Password: (.+)/)
|
||||||
|
if (passwordMatch) {
|
||||||
|
await navigator.clipboard.writeText(passwordMatch[1])
|
||||||
|
// Maybe flash or something, but for now just copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServerSettings = async () => {
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
await updatePublic(serverSettings.public)
|
||||||
|
// Update store
|
||||||
|
store.server.public = serverSettings.public
|
||||||
|
success.value = 'Server settings updated'
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to update settings'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
serverSettings.public = store.server.public
|
||||||
|
loadUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => store.server.public, (newVal) => {
|
||||||
|
serverSettings.public = newVal
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.user-table th, .user-table td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.user-table th {
|
||||||
|
background: var(--soft-color);
|
||||||
|
}
|
||||||
|
.button.small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.button.danger {
|
||||||
|
background: var(--red-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.button.danger:hover {
|
||||||
|
background: #d00;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-row label {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.success-message {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
class ClientClass {
|
class ClientClass {
|
||||||
|
async get(url: string): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -17,6 +33,40 @@ class ClientClass {
|
|||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
async put(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: data !== undefined ? JSON.stringify(data) : undefined
|
||||||
|
})
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
async delete(url: string): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Client = new ClientClass()
|
export const Client = new ClientClass()
|
||||||
|
|||||||
@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
|
|||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const url_users = '/users'
|
||||||
|
|
||||||
|
export async function listUsers() {
|
||||||
|
const data = await Client.get(url_users)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string, password?: string, privileged?: boolean) {
|
||||||
|
const data = await Client.post(url_users, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privileged
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
|
||||||
|
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(username: string) {
|
||||||
|
const data = await Client.delete(`${url_users}/${username}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePublic(publicFlag: boolean) {
|
||||||
|
const data = await Client.put('/config/public', { public: publicFlag })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const useMainStore = defineStore({
|
|||||||
connected: false,
|
connected: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any>,
|
||||||
dialog: '' as '' | 'login' | 'settings',
|
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ dependencies = [
|
|||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"av>=15.0.0",
|
"av>=15.0.0",
|
||||||
"blake3>=1.0.5",
|
"blake3>=1.0.5",
|
||||||
"brotli>=1.1.0",
|
|
||||||
"docopt>=0.6.2",
|
"docopt>=0.6.2",
|
||||||
"inotify>=0.2.12",
|
"inotify>=0.2.12",
|
||||||
"msgspec>=0.19.0",
|
"msgspec>=0.19.0",
|
||||||
@@ -42,6 +41,7 @@ dependencies = [
|
|||||||
"setproctitle>=1.3.6",
|
"setproctitle>=1.3.6",
|
||||||
"stream-zip>=0.0.83",
|
"stream-zip>=0.0.83",
|
||||||
"tomli_w>=1.2.0",
|
"tomli_w>=1.2.0",
|
||||||
|
"zstandard>=0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
Reference in New Issue
Block a user