Compare commits
	
		
			4 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 091d57dba7 | ||
|   | 69a897cfec | ||
|   | 33db2c01b4 | ||
|   | 26addb2f7b | 
| @@ -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" | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -119,8 +119,12 @@ async def watch(req, ws): | ||||
|         # Send updates | ||||
|         while True: | ||||
|             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: | ||||
|         del watching.pubsub[uuid] | ||||
|         watching.pubsub.pop(uuid, None)  # Remove whether it got added yet or not | ||||
|  | ||||
|  | ||||
| def subscribe(uuid, ws): | ||||
|   | ||||
| @@ -43,14 +43,16 @@ async def main_start(app, loop): | ||||
|     app.ctx.threadexec = ThreadPoolExecutor( | ||||
|         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): | ||||
|     quit.set() | ||||
|     await watching.stop(app, loop) | ||||
|     watching.stop(app) | ||||
|     app.ctx.threadexec.shutdown() | ||||
|     logger.debug("Cista worker threads all finished") | ||||
|  | ||||
|  | ||||
| @app.on_request | ||||
|   | ||||
| @@ -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,7 +151,6 @@ def modifies_config(modify): | ||||
|  | ||||
| def load_config(): | ||||
|     global config | ||||
|     if conffile is None: | ||||
|     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) | ||||
|   | ||||
| @@ -24,6 +24,17 @@ pillow_heif.register_heif_opener() | ||||
|  | ||||
| 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>") | ||||
| async def preview(req, path): | ||||
| @@ -69,34 +80,35 @@ def dispatch(path, quality, maxsize, maxzoom): | ||||
|  | ||||
|  | ||||
| def process_image(path, *, maxsize, quality): | ||||
|     t_load_start = perf_counter() | ||||
|     img = Image.open(path) | ||||
|     t_load = perf_counter() | ||||
|     with Image.open(path) as img: | ||||
|         # Force decode to include I/O in load timing | ||||
|         img.load() | ||||
|     t_load_end = perf_counter() | ||||
|         t_proc = perf_counter() | ||||
|         # Resize | ||||
|     orig_w, orig_h = img.size | ||||
|     t_proc_start = perf_counter() | ||||
|     img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize))) | ||||
|     t_proc_end = perf_counter() | ||||
|         w, h = img.size | ||||
|         img.thumbnail((min(w, maxsize), min(h, maxsize))) | ||||
|         # Transpose pixels according to EXIF Orientation | ||||
|         orientation = img.getexif().get(274, 1) | ||||
|         if orientation in EXIF_ORI: | ||||
|             img = img.transpose(EXIF_ORI[orientation]) | ||||
|         # Save as AVIF | ||||
|         imgdata = io.BytesIO() | ||||
|     t_save_start = perf_counter() | ||||
|         t_save = perf_counter() | ||||
|         img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1) | ||||
|     t_save_end = perf_counter() | ||||
|  | ||||
|     t_end = perf_counter() | ||||
|     ret = imgdata.getvalue() | ||||
|  | ||||
|     load_ms = (t_load_end - t_load_start) * 1000 | ||||
|     proc_ms = (t_proc_end - t_proc_start) * 1000 | ||||
|     save_ms = (t_save_end - t_save_start) * 1000 | ||||
|     load_ms = (t_proc - t_load) * 1000 | ||||
|     proc_ms = (t_save - t_proc) * 1000 | ||||
|     save_ms = (t_end - t_save) * 1000 | ||||
|     logger.debug( | ||||
|         "Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB", | ||||
|         "Preview image %s: load=%.1fms process=%.1fms save=%.1fms", | ||||
|         path.name, | ||||
|         load_ms, | ||||
|         proc_ms, | ||||
|         save_ms, | ||||
|         len(ret) / 1024, | ||||
|     ) | ||||
|  | ||||
|     return ret | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import os | ||||
| import re | ||||
| import signal | ||||
| from pathlib import Path | ||||
|  | ||||
| from sanic import Sanic | ||||
| @@ -12,14 +11,6 @@ def run(*, dev=False): | ||||
|     """Run Sanic main process that spawns worker processes to serve HTTP requests.""" | ||||
|     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) | ||||
|     # Silence Sanic's warning about running in production rather than debug | ||||
|     os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" | ||||
|   | ||||
| @@ -440,7 +440,7 @@ def watcher_poll(loop): | ||||
|         quit.wait(0.1 + 8 * dur) | ||||
|  | ||||
|  | ||||
| async def start(app, loop): | ||||
| def start(app, loop): | ||||
|     global rootpath | ||||
|     config.load_config() | ||||
|     rootpath = config.config.path | ||||
| @@ -454,6 +454,6 @@ async def start(app, loop): | ||||
|     app.ctx.watcher.start() | ||||
|  | ||||
|  | ||||
| async def stop(app, loop): | ||||
| def stop(app): | ||||
|     quit.set() | ||||
|     app.ctx.watcher.join() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user