11 Commits

Author SHA1 Message Date
Leo Vasanko
76659c6cdb Remove overly eager Gallery automode. No longer switches to gallery when merely changing sort column. 2025-09-30 19:23:24 -06:00
Leo Vasanko
a44a50878c Improved reliability of direct to folder downloads. 2025-09-30 19:18:30 -06:00
Leo Vasanko
b8816d482c Fix admin UI password reset and user deletion functions. 2025-09-30 18:31:20 -06:00
cfc80d2462 Implement web-based user management / admin setup. (#8)
Implement Admin Settings dialog for user management and toggling the public server flag, not needing CLI for maintenance anymore.
2025-10-01 01:10:33 +01:00
Leo Vasanko
bf604334bd Fix Vue TypeScript module declarations for build 2025-09-30 17:32:14 -06:00
Leo Vasanko
3edf595983 Clean up the remaining uses of print() 2025-08-18 10:49:40 -06:00
Leo Vasanko
6aef2e1208 Use zstd rather than brotli for static file compression. 2025-08-17 16:51:46 -06:00
Leo Vasanko
091d57dba7 Fix typing and import in the config file module. 2025-08-17 10:31:54 -06:00
Leo Vasanko
69a897cfec Startup banner with version display, and --version, using stderr/stdout properly. 2025-08-17 10:31:18 -06:00
Leo Vasanko
33db2c01b4 Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 08:15:01 -06:00
Leo Vasanko
26addb2f7b Image previews improved, all EXIF Orientations handled. 2025-08-17 07:00:52 -06:00
19 changed files with 642 additions and 124 deletions

View File

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

View File

@@ -119,8 +119,12 @@ async def watch(req, ws):
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) await ws.send(await q.get())
except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket
raise
finally: finally:
del watching.pubsub[uuid] watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
def subscribe(uuid, ws): def subscribe(uuid, ws):

View File

@@ -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
@@ -43,14 +43,16 @@ async def main_start(app, loop):
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" max_workers=workers, thread_name_prefix="cista-ioworker"
) )
await watching.start(app, loop) watching.start(app, loop)
@app.after_server_stop # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop
async def main_stop(app, loop): async def main_stop(app, loop):
quit.set() quit.set()
await watching.stop(app, loop) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
logger.debug("Cista worker threads all finished")
@app.on_request @app.on_request
@@ -93,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
@@ -124,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:
@@ -180,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
@@ -194,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)

View File

@@ -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,103 @@ 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.delete("/users/<username>")
async def delete_user(request, username):
verify(request, privileged=True)
if username not in config.config.users:
raise BadRequest("User does not exist")
try:
config.del_user(username)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"})
@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"})

View File

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

View File

@@ -24,6 +24,17 @@ pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}
@bp.get("/<path:path>") @bp.get("/<path:path>")
async def preview(req, path): async def preview(req, path):
@@ -69,34 +80,35 @@ def dispatch(path, quality, maxsize, maxzoom):
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
t_load_start = perf_counter() t_load = perf_counter()
img = Image.open(path) with Image.open(path) as img:
# Force decode to include I/O in load timing # Force decode to include I/O in load timing
img.load() img.load()
t_load_end = perf_counter() t_proc = perf_counter()
# Resize # Resize
orig_w, orig_h = img.size w, h = img.size
t_proc_start = perf_counter() img.thumbnail((min(w, maxsize), min(h, maxsize)))
img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize))) # Transpose pixels according to EXIF Orientation
t_proc_end = perf_counter() orientation = img.getexif().get(274, 1)
# Save as AVIF if orientation in EXIF_ORI:
imgdata = io.BytesIO() img = img.transpose(EXIF_ORI[orientation])
t_save_start = perf_counter() # Save as AVIF
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1) imgdata = io.BytesIO()
t_save_end = perf_counter() t_save = perf_counter()
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_end = perf_counter()
ret = imgdata.getvalue() ret = imgdata.getvalue()
load_ms = (t_load_end - t_load_start) * 1000 load_ms = (t_proc - t_load) * 1000
proc_ms = (t_proc_end - t_proc_start) * 1000 proc_ms = (t_save - t_proc) * 1000
save_ms = (t_save_end - t_save_start) * 1000 save_ms = (t_end - t_save) * 1000
logger.debug( logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB", "Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
path.name, path.name,
load_ms, load_ms,
proc_ms, proc_ms,
save_ms, save_ms,
len(ret) / 1024,
) )
return ret return ret

View File

@@ -1,6 +1,5 @@
import os import os
import re import re
import signal
from pathlib import Path from pathlib import Path
from sanic import Sanic from sanic import Sanic
@@ -12,14 +11,6 @@ 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
# Set up immediate exit on Ctrl+C for faster termination
def signal_handler(signum, frame):
print("\nReceived interrupt signal, exiting immediately...")
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
url, opts = parse_listen(config.config.listen) url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"

View File

@@ -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):

View File

@@ -440,7 +440,7 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur) quit.wait(0.1 + 8 * dur)
async def start(app, loop): def start(app, loop):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
@@ -454,6 +454,6 @@ async def start(app, loop):
app.ctx.watcher.start() app.ctx.watcher.start()
async def stop(app, loop): def stop(app):
quit.set() quit.set()
app.ctx.watcher.join() app.ctx.watcher.join()

6
frontend/env.d.ts vendored
View File

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

View File

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

View File

@@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true }) h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create directory', hdir, error) console.error('Failed to create directory', hdir, error)
return throw new Error(`Failed to create directory ${hdir}: ${error}`)
} }
console.log('Created', hdir) console.log('Created', hdir)
} }
@@ -90,37 +90,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
fileHandle = await h.getFileHandle(name, { create: true }) fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error) console.error('Failed to create file', rel, full, hdir + name, error)
return throw new Error(`Failed to create file ${hdir + name}: ${error}`)
} }
const writable = await fileHandle.createWritable() try {
const url = `/files/${rel}` const writable = await fileHandle.createWritable()
console.log('Fetching', url) const url = `/files/${rel}`
const res = await fetch(url) console.log('Fetching', url)
if (!res.ok) { const res = await fetch(url)
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}` if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`) store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
} throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
} }
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
}
}
await writable.close()
console.log('Saved', hdir + name)
} catch (error) {
console.error('Failed to write file', hdir + name, error)
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
} }
await writable.close()
console.log('Saved', hdir + name)
} }
statReset() statReset()
} }

View File

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

View File

@@ -0,0 +1,265 @@
<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" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</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 copyButtonText = 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 (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1])
if (isButtonClick) {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
success.value = ''
copyButtonText.value = '📋'
}, 3000)
} else {
// Just hide the message when clicking elsewhere
success.value = ''
}
}
}
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>

View File

@@ -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()

View File

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

View File

@@ -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: {

View File

@@ -73,8 +73,8 @@ watchEffect(() => {
store.query = props.query store.query = props.query
}) })
watch(documents, (docs) => { watch([() => props.path.join('/'), () => props.query], () => {
store.prefs.gallery = docs.some(d => d.previewable) store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true }) }, { immediate: true })
</script> </script>

View File

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