Compare commits
No commits in common. "main" and "v1.1.0" have entirely different histories.
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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)
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user