Compare commits

...

4 Commits
v1.0.0 ... main

Author SHA1 Message Date
Leo Vasanko
091d57dba7 Fix typing and import in the config file module. 2025-08-17 10:31:54 -06:00
Leo Vasanko
69a897cfec Startup banner with version display, and --version, using stderr/stdout properly. 2025-08-17 10:31:18 -06:00
Leo Vasanko
33db2c01b4 Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 08:15:01 -06:00
Leo Vasanko
26addb2f7b Image previews improved, all EXIF Orientations handled. 2025-08-17 07:00:52 -06:00
7 changed files with 129 additions and 75 deletions

View File

@ -10,8 +10,24 @@ from cista.util import pwgen
del app, server80.app # Only import needed, for Sanic multiprocessing del app, server80.app # Only import needed, for Sanic multiprocessing
doc = f"""Cista {cista.__version__} - A file storage for the web.
def create_banner():
"""Create a framed banner with the Cista version."""
title = f"Cista {cista.__version__}"
subtitle = "A file storage for the web"
width = max(len(title), len(subtitle)) + 4
return f"""\
{"" * width}
{title:^{width}}
{subtitle:^{width}}
{"" * width}
"""
banner = create_banner()
doc = """\
Usage: Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>] cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password] cista [-c <confdir>] --user <name> [--privileged] [--password]
@ -35,6 +51,14 @@ User management:
--password Reset password --password Reset password
""" """
first_time_help = """\
No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts
cista -l :8000 /path/to/files # Run the server on localhost:8000
See cista --help for other options!
"""
def main(): def main():
# Dev mode doesn't catch exceptions # Dev mode doesn't catch exceptions
@ -44,11 +68,19 @@ def main():
try: try:
return _main() return _main()
except Exception as e: except Exception as e:
print("Error:", e) sys.stderr.write(f"Error: {e}\n")
return 1 return 1
def _main(): def _main():
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
if any(arg in sys.argv for arg in ("--help", "-h")):
sys.stdout.write(banner)
elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n")
return 0
else:
sys.stderr.write(banner)
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
@ -62,18 +94,11 @@ def _main():
path = None path = None
_confdir(args) _confdir(args)
exists = config.conffile.exists() exists = config.conffile.exists()
print(config.conffile, exists)
import_droppy = args["--import-droppy"] import_droppy = args["--import-droppy"]
necessary_opts = exists or import_droppy or path necessary_opts = exists or import_droppy or path
if not necessary_opts: if not necessary_opts:
# Maybe run without arguments # Maybe run without arguments
print(doc) sys.stderr.write(first_time_help)
print(
"No config file found! Get started with one of:\n"
" cista --user yourname --privileged\n"
" cista --import-droppy\n"
" cista -l :8000 /path/to/files\n"
)
return 1 return 1
settings = {} settings = {}
if import_droppy: if import_droppy:
@ -94,7 +119,7 @@ def _main():
# We have no users, so make it public # We have no users, so make it public
settings["public"] = True settings["public"] = True
operation = config.update_config(settings) operation = config.update_config(settings)
print(f"Config {operation}: {config.conffile}") sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve # Prepare to serve
unix = None unix = None
url, _ = serve.parse_listen(config.config.listen) url, _ = serve.parse_listen(config.config.listen)
@ -104,7 +129,7 @@ def _main():
dev = args["--dev"] dev = args["--dev"]
if dev: if dev:
extra += " (dev mode)" extra += " (dev mode)"
print(f"Serving {config.config.path} at {url}{extra}") sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0 return 0
@ -137,7 +162,7 @@ def _user(args):
"public": False, "public": False,
} }
) )
print(f"Config {operation}: {config.conffile}\n") sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
name = args["--user"] name = args["--user"]
if not name or not name.isidentifier(): if not name or not name.isidentifier():
@ -155,12 +180,12 @@ def _user(args):
changes["password"] = pw = pwgen.generate() changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}\n" info += f"\n Password: {pw}\n"
res = config.update_user(name, changes) res = config.update_user(name, changes)
print(info) sys.stderr.write(f"{info}\n")
if res == "read": if res == "read":
print(" No changes") sys.stderr.write(" No changes\n")
if operation == "created": if operation == "created":
print( sys.stderr.write(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n" "Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
) )

View File

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

View File

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

View File

@ -7,9 +7,11 @@ from contextlib import suppress
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
from time import time from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec
import msgspec import msgspec
import msgspec.toml
class Config(msgspec.Struct): class Config(msgspec.Struct):
@ -22,6 +24,13 @@ class Config(msgspec.Struct):
links: dict[str, Link] = {} links: dict[str, Link] = {}
# Typing: arguments for config-modifying functions
P = ParamSpec("P")
ResultStr = Literal["modified", "created", "read"]
RawModifyFunc = Callable[Concatenate[Config, P], Config]
ModifyPublic = Callable[P, ResultStr]
class User(msgspec.Struct, omit_defaults=True): class User(msgspec.Struct, omit_defaults=True):
privileged: bool = False privileged: bool = False
hash: str = "" hash: str = ""
@ -34,11 +43,13 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0 expires: int = 0
config = None # Global variables - initialized during application startup
conffile = None config: Config
conffile: Path
def init_confdir(): def init_confdir() -> None:
global conffile
if p := os.environ.get("CISTA_HOME"): if p := os.environ.get("CISTA_HOME"):
home = Path(p) home = Path(p)
else: else:
@ -49,8 +60,6 @@ def init_confdir():
if not home.is_dir(): if not home.is_dir():
home.mkdir(parents=True, exist_ok=True) home.mkdir(parents=True, exist_ok=True)
home.chmod(0o700) home.chmod(0o700)
global conffile
conffile = home / "db.toml" conffile = home / "db.toml"
@ -77,10 +86,10 @@ def dec_hook(typ, obj):
raise TypeError raise TypeError
def config_update(modify): def config_update(
modify: RawModifyFunc,
) -> ResultStr | Literal["collision"]:
global config global config
if conffile is None:
init_confdir()
tmpname = conffile.with_suffix(".tmp") tmpname = conffile.with_suffix(".tmp")
try: try:
f = tmpname.open("xb") f = tmpname.open("xb")
@ -95,7 +104,7 @@ def config_update(modify):
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook) c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError: except FileNotFoundError:
old = b"" old = b""
c = None c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
c = modify(c) c = modify(c)
new = msgspec.toml.encode(c, enc_hook=enc_hook) new = msgspec.toml.encode(c, enc_hook=enc_hook)
if old == new: if old == new:
@ -118,17 +127,23 @@ def config_update(modify):
return "modified" if old else "created" return "modified" if old else "created"
def modifies_config(modify): def modifies_config(
"""Decorator for functions that modify the config file""" modify: Callable[Concatenate[Config, P], Config],
) -> Callable[P, ResultStr]:
"""Decorator for functions that modify the config file
The decorated function takes as first arg Config and returns it modified.
The wrapper handles atomic modification and returns a string indicating the result.
"""
@wraps(modify) @wraps(modify)
def wrapper(*args, **kwargs): def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
def m(c): def m(c: Config) -> Config:
return modify(c, *args, **kwargs) return modify(c, *args, **kwargs)
# Retry modification in case of write collision # Retry modification in case of write collision
while (c := config_update(m)) == "collision": while (c := config_update(m)) == "collision":
time.sleep(0.01) sleep(0.01)
return c return c
return wrapper return wrapper
@ -136,8 +151,7 @@ def modifies_config(modify):
def load_config(): def load_config():
global config global config
if conffile is None: init_confdir()
init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
@ -145,7 +159,7 @@ def load_config():
def update_config(conf: Config, changes: dict) -> Config: def update_config(conf: Config, changes: dict) -> Config:
"""Create/update the config with new values, respecting changes done by others.""" """Create/update the config with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config # Encode into dict, update values with new, convert to Config
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook) settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings.update(changes) settings.update(changes)
return msgspec.convert(settings, Config, dec_hook=dec_hook) return msgspec.convert(settings, Config, dec_hook=dec_hook)
@ -155,8 +169,13 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
"""Create/update a user with new values, respecting changes done by others.""" """Create/update a user with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config # Encode into dict, update values with new, convert to Config
try: try:
u = conf.users[name].__copy__() # Copy user by converting to dict and back
except (KeyError, AttributeError): u = msgspec.convert(
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
User,
dec_hook=dec_hook,
)
except KeyError:
u = User() u = User()
if "password" in changes: if "password" in changes:
from . import auth from . import auth
@ -165,7 +184,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
del changes["password"] del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook) udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes) udict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}} settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook) settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook) return msgspec.convert(settings, Config, dec_hook=dec_hook)
@ -173,6 +192,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
@modifies_config @modifies_config
def del_user(conf: Config, name: str) -> Config: def del_user(conf: Config, name: str) -> Config:
"""Delete named user account.""" """Delete named user account."""
ret = conf.__copy__() # Create a copy by converting to dict and back
ret.users.pop(name) settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
return ret settings["users"].pop(name)
return msgspec.convert(settings, Config, dec_hook=dec_hook)

View File

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

View File

@ -1,6 +1,5 @@
import os import os
import re import re
import signal
from pathlib import Path from pathlib import Path
from sanic import Sanic from sanic import Sanic
@ -12,14 +11,6 @@ def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app from .app import app
# Set up immediate exit on Ctrl+C for faster termination
def signal_handler(signum, frame):
print("\nReceived interrupt signal, exiting immediately...")
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
url, opts = parse_listen(config.config.listen) url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"

View File

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