Compare commits
	
		
			9 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 76659c6cdb | ||
|   | a44a50878c | ||
|   | b8816d482c | ||
| cfc80d2462 | |||
|   | bf604334bd | ||
|   | 3edf595983 | ||
|   | 6aef2e1208 | ||
|   | 091d57dba7 | ||
|   | 69a897cfec | 
| @@ -10,8 +10,24 @@ from cista.util import pwgen | ||||
|  | ||||
| del app, server80.app  # Only import needed, for Sanic multiprocessing | ||||
|  | ||||
| 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: | ||||
|   cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>] | ||||
|   cista [-c <confdir>] --user <name> [--privileged] [--password] | ||||
| @@ -35,6 +51,14 @@ User management: | ||||
|   --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(): | ||||
|     # Dev mode doesn't catch exceptions | ||||
| @@ -44,11 +68,19 @@ def main(): | ||||
|     try: | ||||
|         return _main() | ||||
|     except Exception as e: | ||||
|         print("Error:", e) | ||||
|         sys.stderr.write(f"Error: {e}\n") | ||||
|         return 1 | ||||
|  | ||||
|  | ||||
| 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) | ||||
|     if args["--user"]: | ||||
|         return _user(args) | ||||
| @@ -62,18 +94,11 @@ def _main(): | ||||
|         path = None | ||||
|     _confdir(args) | ||||
|     exists = config.conffile.exists() | ||||
|     print(config.conffile, exists) | ||||
|     import_droppy = args["--import-droppy"] | ||||
|     necessary_opts = exists or import_droppy or path | ||||
|     if not necessary_opts: | ||||
|         # Maybe run without arguments | ||||
|         print(doc) | ||||
|         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" | ||||
|         ) | ||||
|         sys.stderr.write(first_time_help) | ||||
|         return 1 | ||||
|     settings = {} | ||||
|     if import_droppy: | ||||
| @@ -94,7 +119,7 @@ def _main(): | ||||
|         # We have no users, so make it public | ||||
|         settings["public"] = True | ||||
|     operation = config.update_config(settings) | ||||
|     print(f"Config {operation}: {config.conffile}") | ||||
|     sys.stderr.write(f"Config {operation}: {config.conffile}\n") | ||||
|     # Prepare to serve | ||||
|     unix = None | ||||
|     url, _ = serve.parse_listen(config.config.listen) | ||||
| @@ -104,7 +129,7 @@ def _main(): | ||||
|     dev = args["--dev"] | ||||
|     if dev: | ||||
|         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 | ||||
|     serve.run(dev=dev) | ||||
|     return 0 | ||||
| @@ -137,7 +162,7 @@ def _user(args): | ||||
|                 "public": False, | ||||
|             } | ||||
|         ) | ||||
|         print(f"Config {operation}: {config.conffile}\n") | ||||
|         sys.stderr.write(f"Config {operation}: {config.conffile}\n\n") | ||||
|  | ||||
|     name = args["--user"] | ||||
|     if not name or not name.isidentifier(): | ||||
| @@ -155,12 +180,12 @@ def _user(args): | ||||
|         changes["password"] = pw = pwgen.generate() | ||||
|         info += f"\n  Password: {pw}\n" | ||||
|     res = config.update_user(name, changes) | ||||
|     print(info) | ||||
|     sys.stderr.write(f"{info}\n") | ||||
|     if res == "read": | ||||
|         print("  No changes") | ||||
|         sys.stderr.write("  No changes\n") | ||||
|  | ||||
|     if operation == "created": | ||||
|         print( | ||||
|         sys.stderr.write( | ||||
|             "Now you can run the server:\n  cista    # defaults set: -l :8000 ~/Downloads\n" | ||||
|         ) | ||||
|  | ||||
|   | ||||
							
								
								
									
										27
									
								
								cista/app.py
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								cista/app.py
									
									
									
									
									
								
							| @@ -9,7 +9,6 @@ from stat import S_IFDIR, S_IFREG | ||||
| from urllib.parse import unquote | ||||
| from wsgiref.handlers import format_date_time | ||||
|  | ||||
| import brotli | ||||
| import sanic.helpers | ||||
| from blake3 import blake3 | ||||
| from sanic import Blueprint, Sanic, empty, raw, redirect | ||||
| @@ -17,6 +16,7 @@ from sanic.exceptions import Forbidden, NotFound | ||||
| from sanic.log import logger | ||||
| from setproctitle import setproctitle | ||||
| from stream_zip import ZIP_AUTO, stream_zip | ||||
| from zstandard import ZstdCompressor | ||||
|  | ||||
| from cista import auth, config, preview, session, watching | ||||
| from cista.api import bp | ||||
| @@ -95,6 +95,7 @@ def _load_wwwroot(www): | ||||
|     wwwnew = {} | ||||
|     base = Path(__file__).with_name("wwwroot") | ||||
|     paths = [PurePath()] | ||||
|     zstd = ZstdCompressor(level=10) | ||||
|     while paths: | ||||
|         path = paths.pop(0) | ||||
|         current = base / path | ||||
| @@ -126,11 +127,11 @@ def _load_wwwroot(www): | ||||
|                 else "no-cache", | ||||
|                 "content-type": mime, | ||||
|             } | ||||
|             # Precompress with Brotli | ||||
|             br = brotli.compress(data) | ||||
|             if len(br) >= len(data): | ||||
|                 br = False | ||||
|             wwwnew[name] = data, br, headers | ||||
|             # Precompress with ZSTD | ||||
|             zs = zstd.compress(data) | ||||
|             if len(zs) >= len(data): | ||||
|                 zs = False | ||||
|             wwwnew[name] = data, zs, headers | ||||
|     if not wwwnew: | ||||
|         msg = f"Web frontend missing from {base}\n  Did you forget: hatch build\n" | ||||
|         if not www: | ||||
| @@ -182,9 +183,9 @@ async def refresh_wwwroot(): | ||||
|                 for name in sorted(set(wwwold) - set(www)): | ||||
|                     changes += f"Deleted /{name}\n" | ||||
|                 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: | ||||
|                 print(f"Error loading wwwroot: {e!r}") | ||||
|                 logger.error(f"Error loading wwwroot: {e!r}") | ||||
|             await asyncio.sleep(0.5) | ||||
|     except asyncio.CancelledError: | ||||
|         pass | ||||
| @@ -196,14 +197,14 @@ async def wwwroot(req, path=""): | ||||
|     name = unquote(path) | ||||
|     if name not in www: | ||||
|         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"]: | ||||
|         # The client has it cached, respond 304 Not Modified | ||||
|         return empty(304, headers=headers) | ||||
|     # Brotli compressed? | ||||
|     if br and "br" in req.headers.accept_encoding.split(", "): | ||||
|         headers = {**headers, "content-encoding": "br"} | ||||
|         data = br | ||||
|     # Zstandard compressed? | ||||
|     if zs and "zstd" in req.headers.accept_encoding.split(", "): | ||||
|         headers = {**headers, "content-encoding": "zstd"} | ||||
|         data = zs | ||||
|     return raw(data, headers=headers) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										101
									
								
								cista/auth.py
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								cista/auth.py
									
									
									
									
									
								
							| @@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect | ||||
| from sanic.exceptions import BadRequest, Forbidden, Unauthorized | ||||
|  | ||||
| from cista import config, session | ||||
| from cista.util import pwgen | ||||
|  | ||||
| _argon = argon2.PasswordHasher() | ||||
| _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"}) | ||||
|     session.create(res, username) | ||||
|     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"}) | ||||
|   | ||||
| @@ -7,9 +7,11 @@ from contextlib import suppress | ||||
| from functools import wraps | ||||
| from hashlib import sha256 | ||||
| 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.toml | ||||
|  | ||||
|  | ||||
| class Config(msgspec.Struct): | ||||
| @@ -22,6 +24,13 @@ class Config(msgspec.Struct): | ||||
|     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): | ||||
|     privileged: bool = False | ||||
|     hash: str = "" | ||||
| @@ -34,11 +43,13 @@ class Link(msgspec.Struct, omit_defaults=True): | ||||
|     expires: int = 0 | ||||
|  | ||||
|  | ||||
| config = None | ||||
| conffile = None | ||||
| # Global variables - initialized during application startup | ||||
| config: Config | ||||
| conffile: Path | ||||
|  | ||||
|  | ||||
| def init_confdir(): | ||||
| def init_confdir() -> None: | ||||
|     global conffile | ||||
|     if p := os.environ.get("CISTA_HOME"): | ||||
|         home = Path(p) | ||||
|     else: | ||||
| @@ -49,8 +60,6 @@ def init_confdir(): | ||||
|     if not home.is_dir(): | ||||
|         home.mkdir(parents=True, exist_ok=True) | ||||
|         home.chmod(0o700) | ||||
|  | ||||
|     global conffile | ||||
|     conffile = home / "db.toml" | ||||
|  | ||||
|  | ||||
| @@ -77,10 +86,10 @@ def dec_hook(typ, obj): | ||||
|     raise TypeError | ||||
|  | ||||
|  | ||||
| def config_update(modify): | ||||
| def config_update( | ||||
|     modify: RawModifyFunc, | ||||
| ) -> ResultStr | Literal["collision"]: | ||||
|     global config | ||||
|     if conffile is None: | ||||
|         init_confdir() | ||||
|     tmpname = conffile.with_suffix(".tmp") | ||||
|     try: | ||||
|         f = tmpname.open("xb") | ||||
| @@ -95,7 +104,7 @@ def config_update(modify): | ||||
|             c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook) | ||||
|         except FileNotFoundError: | ||||
|             old = b"" | ||||
|             c = None | ||||
|             c = Config(path=Path(), listen="", secret=secrets.token_hex(12)) | ||||
|         c = modify(c) | ||||
|         new = msgspec.toml.encode(c, enc_hook=enc_hook) | ||||
|         if old == new: | ||||
| @@ -118,17 +127,23 @@ def config_update(modify): | ||||
|     return "modified" if old else "created" | ||||
|  | ||||
|  | ||||
| def modifies_config(modify): | ||||
|     """Decorator for functions that modify the config file""" | ||||
| def modifies_config( | ||||
|     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) | ||||
|     def wrapper(*args, **kwargs): | ||||
|         def m(c): | ||||
|     def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr: | ||||
|         def m(c: Config) -> Config: | ||||
|             return modify(c, *args, **kwargs) | ||||
|  | ||||
|         # Retry modification in case of write collision | ||||
|         while (c := config_update(m)) == "collision": | ||||
|             time.sleep(0.01) | ||||
|             sleep(0.01) | ||||
|         return c | ||||
|  | ||||
|     return wrapper | ||||
| @@ -136,8 +151,7 @@ def modifies_config(modify): | ||||
|  | ||||
| def load_config(): | ||||
|     global config | ||||
|     if conffile is None: | ||||
|         init_confdir() | ||||
|     init_confdir() | ||||
|     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: | ||||
|     """Create/update the config with new values, respecting changes done by others.""" | ||||
|     # 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) | ||||
|     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.""" | ||||
|     # Encode into dict, update values with new, convert to Config | ||||
|     try: | ||||
|         u = conf.users[name].__copy__() | ||||
|     except (KeyError, AttributeError): | ||||
|         # Copy user by converting to dict and back | ||||
|         u = msgspec.convert( | ||||
|             msgspec.to_builtins(conf.users[name], enc_hook=enc_hook), | ||||
|             User, | ||||
|             dec_hook=dec_hook, | ||||
|         ) | ||||
|     except KeyError: | ||||
|         u = User() | ||||
|     if "password" in changes: | ||||
|         from . import auth | ||||
| @@ -165,7 +184,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config: | ||||
|         del changes["password"] | ||||
|     udict = msgspec.to_builtins(u, enc_hook=enc_hook) | ||||
|     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) | ||||
|     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 | ||||
| def del_user(conf: Config, name: str) -> Config: | ||||
|     """Delete named user account.""" | ||||
|     ret = conf.__copy__() | ||||
|     ret.users.pop(name) | ||||
|     return ret | ||||
|     # Create a copy by converting to dict and back | ||||
|     settings = msgspec.to_builtins(conf, enc_hook=enc_hook) | ||||
|     settings["users"].pop(name) | ||||
|     return msgspec.convert(settings, Config, dec_hook=dec_hook) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| from time import monotonic | ||||
| from typing import Callable | ||||
|  | ||||
|  | ||||
| class LRUCache: | ||||
| @@ -12,7 +13,7 @@ class LRUCache: | ||||
|         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. | ||||
|  | ||||
| @@ -50,7 +51,6 @@ class LRUCache: | ||||
|         # Add/restore to end of cache | ||||
|         self.cache.insert(0, (key, f, monotonic())) | ||||
|         self.expire_items() | ||||
|         print(self.cache) | ||||
|         return f | ||||
|  | ||||
|     def expire_items(self): | ||||
|   | ||||
							
								
								
									
										6
									
								
								frontend/env.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								frontend/env.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1 +1,7 @@ | ||||
| /// <reference types="vite/client" /> | ||||
|  | ||||
| declare module '*.vue' { | ||||
|   import type { DefineComponent } from 'vue' | ||||
|   const component: DefineComponent<{}, {}, any> | ||||
|   export default component | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <template> | ||||
|   <LoginModal /> | ||||
|   <SettingsModal /> | ||||
|   <UserManagementModal /> | ||||
|   <header> | ||||
|     <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> | ||||
|       <HeaderSelected :path="path.pathList" /> | ||||
| @@ -28,6 +29,7 @@ import { computed } from 'vue' | ||||
| import Router from '@/router/index' | ||||
| import type { SortOrder } from './utils/docsort' | ||||
| import type SettingsModalVue from './components/SettingsModal.vue' | ||||
| import UserManagementModal from './components/UserManagementModal.vue' | ||||
|  | ||||
| interface Path { | ||||
|   path: string | ||||
|   | ||||
| @@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl | ||||
|         h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true }) | ||||
|       } catch (error) { | ||||
|         console.error('Failed to create directory', hdir, error) | ||||
|         return | ||||
|         throw new Error(`Failed to create directory ${hdir}: ${error}`) | ||||
|       } | ||||
|       console.log('Created', hdir) | ||||
|     } | ||||
| @@ -90,37 +90,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl | ||||
|       fileHandle = await h.getFileHandle(name, { create: true }) | ||||
|     } catch (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() | ||||
|     const url = `/files/${rel}` | ||||
|     console.log('Fetching', url) | ||||
|     const res = await fetch(url) | ||||
|     if (!res.ok) { | ||||
|       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 | ||||
|     try { | ||||
|       const writable = await fileHandle.createWritable() | ||||
|       const url = `/files/${rel}` | ||||
|       console.log('Fetching', url) | ||||
|       const res = await fetch(url) | ||||
|       if (!res.ok) { | ||||
|         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 | ||||
|         } | ||||
|       } | ||||
|       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() | ||||
| } | ||||
|   | ||||
| @@ -73,7 +73,10 @@ watchEffect(() => { | ||||
| const settingsMenu = (e: Event) => { | ||||
|   // show the context menu | ||||
|   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) { | ||||
|     items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) | ||||
|   } else { | ||||
|   | ||||
							
								
								
									
										265
									
								
								frontend/src/components/UserManagementModal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								frontend/src/components/UserManagementModal.vue
									
									
									
									
									
										Normal 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> | ||||
| @@ -1,4 +1,20 @@ | ||||
| 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> { | ||||
|     const res = await fetch(url, { | ||||
|       method: 'POST', | ||||
| @@ -17,6 +33,40 @@ class ClientClass { | ||||
|     if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message) | ||||
|     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() | ||||
|   | ||||
| @@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p | ||||
|   }) | ||||
|   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, | ||||
|     cursor: '' as string, | ||||
|     server: {} as Record<string, any>, | ||||
|     dialog: '' as '' | 'login' | 'settings', | ||||
|     dialog: '' as '' | 'login' | 'settings' | 'usermgmt', | ||||
|     uprogress: {} as any, | ||||
|     dprogress: {} as any, | ||||
|     prefs: { | ||||
|   | ||||
| @@ -73,8 +73,8 @@ watchEffect(() => { | ||||
|   store.query = props.query | ||||
| }) | ||||
|  | ||||
| watch(documents, (docs) => { | ||||
|   store.prefs.gallery = docs.some(d => d.previewable) | ||||
| watch([() => props.path.join('/'), () => props.query], () => { | ||||
|   store.prefs.gallery = documents.value.some(d => d.previewable) | ||||
| }, { immediate: true }) | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -27,7 +27,6 @@ dependencies = [ | ||||
|     "argon2-cffi>=25.1.0", | ||||
|     "av>=15.0.0", | ||||
|     "blake3>=1.0.5", | ||||
|     "brotli>=1.1.0", | ||||
|     "docopt>=0.6.2", | ||||
|     "inotify>=0.2.12", | ||||
|     "msgspec>=0.19.0", | ||||
| @@ -42,6 +41,7 @@ dependencies = [ | ||||
|     "setproctitle>=1.3.6", | ||||
|     "stream-zip>=0.0.83", | ||||
|     "tomli_w>=1.2.0", | ||||
|     "zstandard>=0.24.0", | ||||
| ] | ||||
|  | ||||
| [project.urls] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user