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
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]
@ -51,14 +35,6 @@ 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
@ -68,19 +44,11 @@ def main():
try:
return _main()
except Exception as e:
sys.stderr.write(f"Error: {e}\n")
print("Error:", e)
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)
@ -94,11 +62,18 @@ 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
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
settings = {}
if import_droppy:
@ -119,7 +94,7 @@ def _main():
# We have no users, so make it public
settings["public"] = True
operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
print(f"Config {operation}: {config.conffile}")
# Prepare to serve
unix = None
url, _ = serve.parse_listen(config.config.listen)
@ -129,7 +104,7 @@ def _main():
dev = args["--dev"]
if dev:
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
serve.run(dev=dev)
return 0
@ -162,7 +137,7 @@ def _user(args):
"public": False,
}
)
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
print(f"Config {operation}: {config.conffile}\n")
name = args["--user"]
if not name or not name.isidentifier():
@ -180,12 +155,12 @@ def _user(args):
changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}\n"
res = config.update_user(name, changes)
sys.stderr.write(f"{info}\n")
print(info)
if res == "read":
sys.stderr.write(" No changes\n")
print(" No changes")
if operation == "created":
sys.stderr.write(
print(
"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
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:
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
del watching.pubsub[uuid]
def subscribe(uuid, ws):

View File

@ -43,16 +43,14 @@ async def main_start(app, loop):
app.ctx.threadexec = ThreadPoolExecutor(
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.before_server_stop
@app.after_server_stop
async def main_stop(app, loop):
quit.set()
watching.stop(app)
await watching.stop(app, loop)
app.ctx.threadexec.shutdown()
logger.debug("Cista worker threads all finished")
@app.on_request

View File

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

View File

@ -24,17 +24,6 @@ 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):
@ -80,35 +69,34 @@ def dispatch(path, quality, maxsize, maxzoom):
def process_image(path, *, maxsize, quality):
t_load = perf_counter()
with Image.open(path) as img:
t_load_start = perf_counter()
img = Image.open(path)
# Force decode to include I/O in load timing
img.load()
t_proc = perf_counter()
t_load_end = perf_counter()
# Resize
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])
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()
# Save as AVIF
imgdata = io.BytesIO()
t_save = perf_counter()
t_save_start = 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_proc - t_load) * 1000
proc_ms = (t_save - t_proc) * 1000
save_ms = (t_end - t_save) * 1000
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
logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB",
path.name,
load_ms,
proc_ms,
save_ms,
len(ret) / 1024,
)
return ret

View File

@ -1,5 +1,6 @@
import os
import re
import signal
from pathlib import Path
from sanic import Sanic
@ -11,6 +12,14 @@ 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"

View File

@ -440,7 +440,7 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur)
def start(app, loop):
async def start(app, loop):
global rootpath
config.load_config()
rootpath = config.config.path
@ -454,6 +454,6 @@ def start(app, loop):
app.ctx.watcher.start()
def stop(app):
async def stop(app, loop):
quit.set()
app.ctx.watcher.join()