Compare commits
	
		
			8 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cfc80d2462 | |||
|   | bf604334bd | ||
|   | 3edf595983 | ||
|   | 6aef2e1208 | ||
|   | 091d57dba7 | ||
|   | 69a897cfec | ||
|   | 33db2c01b4 | ||
|   | 26addb2f7b | 
| @@ -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" | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
							
								
								
									
										35
									
								
								cista/app.py
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								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 | ||||||
| @@ -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) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
| @@ -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
									
									
								
							
							
						
						
									
										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