Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

7 changed files with 75 additions and 129 deletions

View File

@ -10,24 +10,8 @@ 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]
@ -51,14 +35,6 @@ 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
@ -68,19 +44,11 @@ def main():
try: try:
return _main() return _main()
except Exception as e: except Exception as e:
sys.stderr.write(f"Error: {e}\n") print("Error:", e)
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)
@ -94,11 +62,18 @@ 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
sys.stderr.write(first_time_help) 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"
)
return 1 return 1
settings = {} settings = {}
if import_droppy: if import_droppy:
@ -119,7 +94,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)
sys.stderr.write(f"Config {operation}: {config.conffile}\n") print(f"Config {operation}: {config.conffile}")
# Prepare to serve # Prepare to serve
unix = None unix = None
url, _ = serve.parse_listen(config.config.listen) url, _ = serve.parse_listen(config.config.listen)
@ -129,7 +104,7 @@ def _main():
dev = args["--dev"] dev = args["--dev"]
if dev: if dev:
extra += " (dev mode)" extra += " (dev mode)"
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n") print(f"Serving {config.config.path} at {url}{extra}")
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0 return 0
@ -162,7 +137,7 @@ def _user(args):
"public": False, "public": False,
} }
) )
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n") print(f"Config {operation}: {config.conffile}\n")
name = args["--user"] name = args["--user"]
if not name or not name.isidentifier(): if not name or not name.isidentifier():
@ -180,12 +155,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)
sys.stderr.write(f"{info}\n") print(info)
if res == "read": if res == "read":
sys.stderr.write(" No changes\n") print(" No changes")
if operation == "created": if operation == "created":
sys.stderr.write( print(
"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,12 +119,8 @@ 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:
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not del watching.pubsub[uuid]
def subscribe(uuid, ws): def subscribe(uuid, ws):

View File

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

View File

@ -7,11 +7,9 @@ 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 sleep, time from time import time
from typing import Callable, Concatenate, Literal, ParamSpec
import msgspec import msgspec
import msgspec.toml
class Config(msgspec.Struct): class Config(msgspec.Struct):
@ -24,13 +22,6 @@ 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 = ""
@ -43,13 +34,11 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0 expires: int = 0
# Global variables - initialized during application startup config = None
config: Config conffile = None
conffile: Path
def init_confdir() -> None: def init_confdir():
global conffile
if p := os.environ.get("CISTA_HOME"): if p := os.environ.get("CISTA_HOME"):
home = Path(p) home = Path(p)
else: else:
@ -60,6 +49,8 @@ def init_confdir() -> None:
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"
@ -86,10 +77,10 @@ def dec_hook(typ, obj):
raise TypeError raise TypeError
def config_update( def config_update(modify):
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")
@ -104,7 +95,7 @@ def config_update(
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 = Config(path=Path(), listen="", secret=secrets.token_hex(12)) c = None
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:
@ -127,23 +118,17 @@ def config_update(
return "modified" if old else "created" return "modified" if old else "created"
def modifies_config( def modifies_config(modify):
modify: Callable[Concatenate[Config, P], Config], """Decorator for functions that modify the config file"""
) -> 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: P.args, **kwargs: P.kwargs) -> ResultStr: def wrapper(*args, **kwargs):
def m(c: Config) -> Config: def m(c):
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":
sleep(0.01) time.sleep(0.01)
return c return c
return wrapper return wrapper
@ -151,7 +136,8 @@ def modifies_config(
def load_config(): def load_config():
global config global config
init_confdir() if conffile is None:
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)
@ -159,7 +145,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 = msgspec.to_builtins(conf, enc_hook=enc_hook) settings = {} if conf is None else 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)
@ -169,13 +155,8 @@ 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:
# Copy user by converting to dict and back u = conf.users[name].__copy__()
u = msgspec.convert( except (KeyError, AttributeError):
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
@ -184,7 +165,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) settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
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)
@ -192,7 +173,6 @@ 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."""
# Create a copy by converting to dict and back ret = conf.__copy__()
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) ret.users.pop(name)
settings["users"].pop(name) return ret
return msgspec.convert(settings, Config, dec_hook=dec_hook)

View File

@ -24,17 +24,6 @@ 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):
@ -80,35 +69,34 @@ def dispatch(path, quality, maxsize, maxzoom):
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
t_load = perf_counter() t_load_start = perf_counter()
with Image.open(path) as img: img = Image.open(path)
# Force decode to include I/O in load timing # Force decode to include I/O in load timing
img.load() img.load()
t_proc = perf_counter() t_load_end = perf_counter()
# Resize # Resize
w, h = img.size orig_w, orig_h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize))) t_proc_start = perf_counter()
# Transpose pixels according to EXIF Orientation img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize)))
orientation = img.getexif().get(274, 1) t_proc_end = perf_counter()
if orientation in EXIF_ORI: # Save as AVIF
img = img.transpose(EXIF_ORI[orientation]) imgdata = io.BytesIO()
# Save as AVIF t_save_start = perf_counter()
imgdata = io.BytesIO() img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_save = perf_counter() t_save_end = 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_proc - t_load) * 1000 load_ms = (t_load_end - t_load_start) * 1000
proc_ms = (t_save - t_proc) * 1000 proc_ms = (t_proc_end - t_proc_start) * 1000
save_ms = (t_end - t_save) * 1000 save_ms = (t_save_end - t_save_start) * 1000
logger.debug( logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms", "Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB",
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,5 +1,6 @@
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
@ -11,6 +12,14 @@ 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)
def start(app, loop): async 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 @@ def start(app, loop):
app.ctx.watcher.start() app.ctx.watcher.start()
def stop(app): async def stop(app, loop):
quit.set() quit.set()
app.ctx.watcher.join() app.ctx.watcher.join()