Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146497d731 | ||
|
|
442816a0ae | ||
|
|
d32afa6016 | ||
|
|
fa60c962c4 | ||
|
|
e55e11b399 | ||
|
|
b6c21152e7 | ||
|
|
f354fc5c71 | ||
|
|
5bda809921 | ||
|
|
2cc92cd786 | ||
|
|
ba6380e71e | ||
|
|
0d853032bf | ||
|
|
1cb512e65d | ||
|
|
972aaee9fe | ||
|
|
055eaa8a21 | ||
|
|
05fb81c36d | ||
|
|
6639174e8f | ||
|
|
5e2e71eafb | ||
|
|
7e4c5bc911 | ||
|
|
bfcce1b80e | ||
|
|
2bd8d4a323 |
+1
-1
@@ -4,5 +4,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
/cista/_version.py
|
/cista/_version.py
|
||||||
/cista/wwwroot/*
|
/cista/frontend-build/
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
+41
-16
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@ fileserver = FileServer()
|
|||||||
|
|
||||||
|
|
||||||
@bp.before_server_start
|
@bp.before_server_start
|
||||||
async def start_fileserver(app, _):
|
async def start_fileserver(app):
|
||||||
await fileserver.start()
|
await fileserver.start()
|
||||||
|
|
||||||
|
|
||||||
@bp.after_server_stop
|
@bp.after_server_stop
|
||||||
async def stop_fileserver(app, _):
|
async def stop_fileserver(app):
|
||||||
await fileserver.stop()
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-18
@@ -9,7 +9,6 @@ from stat import S_IFDIR, S_IFREG
|
|||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
import brotli
|
|
||||||
import sanic.helpers
|
import sanic.helpers
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||||
@@ -17,6 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
|
|||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, watching
|
from cista import auth, config, preview, session, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
@@ -36,19 +36,19 @@ setproctitle("cista-main")
|
|||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
async def main_start(app, loop):
|
async def main_start(app):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
setproctitle(f"cista {config.config.path.name}")
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
workers = max(2, min(8, cpu_count()))
|
workers = max(2, min(8, cpu_count()))
|
||||||
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)
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
@app.before_server_stop
|
@app.before_server_stop
|
||||||
async def main_stop(app, loop):
|
async def main_stop(app):
|
||||||
quit.set()
|
quit.set()
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
@@ -75,7 +75,7 @@ async def use_session(req):
|
|||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
def http_fileserver(app, _):
|
def http_fileserver(app):
|
||||||
bp = Blueprint("fileserver")
|
bp = Blueprint("fileserver")
|
||||||
bp.on_request(auth.verify)
|
bp.on_request(auth.verify)
|
||||||
bp.static(
|
bp.static(
|
||||||
@@ -93,8 +93,9 @@ www = {}
|
|||||||
|
|
||||||
def _load_wwwroot(www):
|
def _load_wwwroot(www):
|
||||||
wwwnew = {}
|
wwwnew = {}
|
||||||
base = Path(__file__).with_name("wwwroot")
|
base = Path(__file__).with_name("frontend-build")
|
||||||
paths = [PurePath()]
|
paths = [PurePath()]
|
||||||
|
zstd = ZstdCompressor(level=18)
|
||||||
while paths:
|
while paths:
|
||||||
path = paths.pop(0)
|
path = paths.pop(0)
|
||||||
current = base / path
|
current = base / path
|
||||||
@@ -126,11 +127,11 @@ def _load_wwwroot(www):
|
|||||||
else "no-cache",
|
else "no-cache",
|
||||||
"content-type": mime,
|
"content-type": mime,
|
||||||
}
|
}
|
||||||
# Precompress with Brotli
|
# Precompress with ZSTD
|
||||||
br = brotli.compress(data)
|
zs = zstd.compress(data)
|
||||||
if len(br) >= len(data):
|
if len(zs) >= len(data):
|
||||||
br = False
|
zs = False
|
||||||
wwwnew[name] = data, br, headers
|
wwwnew[name] = data, zs, headers
|
||||||
if not wwwnew:
|
if not wwwnew:
|
||||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||||
if not www:
|
if not www:
|
||||||
@@ -182,9 +183,9 @@ async def refresh_wwwroot():
|
|||||||
for name in sorted(set(wwwold) - set(www)):
|
for name in sorted(set(wwwold) - set(www)):
|
||||||
changes += f"Deleted /{name}\n"
|
changes += f"Deleted /{name}\n"
|
||||||
if changes:
|
if changes:
|
||||||
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading wwwroot: {e!r}")
|
logger.error(f"Error loading wwwroot: {e!r}")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -196,14 +197,14 @@ async def wwwroot(req, path=""):
|
|||||||
name = unquote(path)
|
name = unquote(path)
|
||||||
if name not in www:
|
if name not in www:
|
||||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||||
data, br, headers = www[name]
|
data, zs, headers = www[name]
|
||||||
if req.headers.if_none_match == headers["etag"]:
|
if req.headers.if_none_match == headers["etag"]:
|
||||||
# The client has it cached, respond 304 Not Modified
|
# The client has it cached, respond 304 Not Modified
|
||||||
return empty(304, headers=headers)
|
return empty(304, headers=headers)
|
||||||
# Brotli compressed?
|
# Zstandard compressed?
|
||||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
if zs and "zstd" in req.headers.accept_encoding.split(", "):
|
||||||
headers = {**headers, "content-encoding": "br"}
|
headers = {**headers, "content-encoding": "zstd"}
|
||||||
data = br
|
data = zs
|
||||||
return raw(data, headers=headers)
|
return raw(data, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+101
@@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect
|
|||||||
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||||
|
|
||||||
from cista import config, session
|
from cista import config, session
|
||||||
|
from cista.util import pwgen
|
||||||
|
|
||||||
_argon = argon2.PasswordHasher()
|
_argon = argon2.PasswordHasher()
|
||||||
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||||
@@ -191,3 +192,103 @@ async def change_password(request):
|
|||||||
res = json({"message": "Password updated"})
|
res = json({"message": "Password updated"})
|
||||||
session.create(res, username)
|
session.create(res, username)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/users")
|
||||||
|
async def list_users(request):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
users = []
|
||||||
|
for name, user in config.config.users.items():
|
||||||
|
users.append(
|
||||||
|
{
|
||||||
|
"username": name,
|
||||||
|
"privileged": user.privileged,
|
||||||
|
"lastSeen": user.lastSeen,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json({"users": users})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/users")
|
||||||
|
async def create_user(request):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
if request.headers.content_type == "application/json":
|
||||||
|
username = request.json["username"]
|
||||||
|
password = request.json.get("password")
|
||||||
|
privileged = request.json.get("privileged", False)
|
||||||
|
else:
|
||||||
|
username = request.form["username"][0]
|
||||||
|
password = request.form.get("password", [None])[0]
|
||||||
|
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
|
||||||
|
if not username or not username.isidentifier():
|
||||||
|
raise ValueError("Invalid username")
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
if username in config.config.users:
|
||||||
|
raise BadRequest("User already exists")
|
||||||
|
if not password:
|
||||||
|
password = pwgen.generate()
|
||||||
|
changes = {"privileged": privileged}
|
||||||
|
changes["hash"] = _argon.hash(_pwnorm(password))
|
||||||
|
try:
|
||||||
|
config.update_user(username, changes)
|
||||||
|
except Exception as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
return json({"message": f"User {username} created", "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/users/<username>")
|
||||||
|
async def update_user(request, username):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
if request.headers.content_type == "application/json":
|
||||||
|
changes = request.json
|
||||||
|
else:
|
||||||
|
changes = {}
|
||||||
|
if "password" in request.form:
|
||||||
|
changes["password"] = request.form["password"][0]
|
||||||
|
if "privileged" in request.form:
|
||||||
|
changes["privileged"] = request.form["privileged"][0].lower() == "true"
|
||||||
|
except KeyError as e:
|
||||||
|
raise BadRequest("Missing fields") from e
|
||||||
|
password_response = None
|
||||||
|
if "password" in changes:
|
||||||
|
if changes["password"] == "":
|
||||||
|
changes["password"] = pwgen.generate()
|
||||||
|
password_response = changes["password"]
|
||||||
|
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
|
||||||
|
del changes["password"]
|
||||||
|
if not changes:
|
||||||
|
return json({"message": "No changes"})
|
||||||
|
try:
|
||||||
|
config.update_user(username, changes)
|
||||||
|
except Exception as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
response = {"message": f"User {username} updated"}
|
||||||
|
if password_response:
|
||||||
|
response["password"] = password_response
|
||||||
|
return json(response)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/users/<username>")
|
||||||
|
async def delete_user(request, username):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
if username not in config.config.users:
|
||||||
|
raise BadRequest("User does not exist")
|
||||||
|
try:
|
||||||
|
config.del_user(username)
|
||||||
|
except Exception as e:
|
||||||
|
raise BadRequest(str(e)) from e
|
||||||
|
return json({"message": f"User {username} deleted"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/config/public")
|
||||||
|
async def update_public(request):
|
||||||
|
verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
public = request.json["public"]
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing public field") from None
|
||||||
|
config.update_config({"public": public})
|
||||||
|
return json({"message": "Public setting updated"})
|
||||||
|
|||||||
+44
-24
@@ -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)
|
||||||
|
|||||||
+63
-40
@@ -13,7 +13,7 @@ import fitz # PyMuPDF
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pillow_heif
|
import pillow_heif
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sanic import Blueprint, empty, raw
|
from sanic import Blueprint, empty, raw, redirect
|
||||||
from sanic.exceptions import NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ async def preview(req, path):
|
|||||||
maxzoom = float(req.args.get("zoom", 2.0))
|
maxzoom = float(req.args.get("zoom", 2.0))
|
||||||
quality = int(req.args.get("q", 60))
|
quality = int(req.args.get("q", 60))
|
||||||
rel = PurePosixPath(sanitize(unquote(path)))
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
path = config.config.path / rel
|
filepath = config.config.path / rel
|
||||||
stat = path.lstat()
|
stat = filepath.lstat()
|
||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
savename = PurePosixPath(path.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": format_date_time(stat.st_mtime),
|
"last-modified": format_date_time(stat.st_mtime),
|
||||||
@@ -61,22 +61,30 @@ async def preview(req, path):
|
|||||||
# The client has it cached, respond 304 Not Modified
|
# The client has it cached, respond 304 Not Modified
|
||||||
return empty(304, headers=headers)
|
return empty(304, headers=headers)
|
||||||
|
|
||||||
if not path.is_file():
|
if not filepath.is_file():
|
||||||
raise NotFound("File not found")
|
raise NotFound("File not found")
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
|
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||||
)
|
)
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom):
|
def dispatch(path, quality, maxsize, maxzoom):
|
||||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
try:
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||||
type, _ = mimetypes.guess_type(path.name)
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
if type and type.startswith("video/"):
|
type, _ = mimetypes.guess_type(path.name)
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
if type and type.startswith("video/"):
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Cannot generate preview for {path.name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error generating preview for {path.name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
@@ -121,7 +129,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|||||||
w, h = page.rect[2:4]
|
w, h = page.rect[2:4]
|
||||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
mat = fitz.Matrix(zoom, zoom)
|
mat = fitz.Matrix(zoom, zoom)
|
||||||
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined]
|
pix = page.get_pixmap(matrix=mat)
|
||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
@@ -166,35 +174,49 @@ def process_video(path, *, maxsize, quality):
|
|||||||
new_height = int(frame.height * scale_factor)
|
new_height = int(frame.height * scale_factor)
|
||||||
frame = frame.reformat(width=new_width, height=new_height)
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
# Simple rotation detection and logging
|
# Apply EXIF rotation if present
|
||||||
if frame.rotation:
|
if frame.rotation:
|
||||||
try:
|
# frame.rotation indicates clockwise rotation needed to display correctly
|
||||||
fplanes = frame.to_ndarray()
|
# np.rot90 rotates counter-clockwise, so we negate k
|
||||||
# Split into Y, U, V planes of proper dimensions
|
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
||||||
planes = [
|
if k == 2:
|
||||||
fplanes[: frame.height],
|
# 180° rotation can be done in YUV420p, preserving HDR
|
||||||
fplanes[frame.height : frame.height + frame.height // 4].reshape(
|
try:
|
||||||
frame.height // 2, frame.width // 2
|
fplanes = frame.to_ndarray()
|
||||||
),
|
# Split into Y, U, V planes of proper dimensions
|
||||||
fplanes[frame.height + frame.height // 4 :].reshape(
|
planes = [
|
||||||
frame.height // 2, frame.width // 2
|
fplanes[: frame.height],
|
||||||
),
|
fplanes[
|
||||||
]
|
frame.height : frame.height + frame.height // 4
|
||||||
# Rotate
|
].reshape(frame.height // 2, frame.width // 2),
|
||||||
planes = [np.rot90(p, frame.rotation // 90) for p in planes]
|
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||||
# Restore PyAV format
|
frame.height // 2, frame.width // 2
|
||||||
planes = np.hstack([p.flat for p in planes]).reshape(
|
),
|
||||||
-1, planes[0].shape[1]
|
]
|
||||||
)
|
# Rotate each plane by 180°
|
||||||
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
planes = [np.rot90(p, 2) for p in planes]
|
||||||
del planes, fplanes
|
# Restore PyAV format
|
||||||
except Exception as e:
|
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||||
if "not yet supported" in str(e):
|
-1, planes[0].shape[1]
|
||||||
logger.warning(
|
)
|
||||||
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}"
|
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||||
|
del planes, fplanes
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error rotating video frame by 180°: {e}")
|
||||||
|
elif k in (1, 3):
|
||||||
|
# 90° or 270° rotation requires RGB conversion (loses HDR)
|
||||||
|
try:
|
||||||
|
rgb = frame.to_ndarray(format="rgb24")
|
||||||
|
rgb = np.rot90(rgb, k)
|
||||||
|
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
|
||||||
|
frame = frame.reformat(
|
||||||
|
format="yuv420p"
|
||||||
|
) # Convert back for encoding
|
||||||
|
del rgb
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Error rotating video frame by {frame.rotation}°: {e}"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.exception(f"Error rotating video frame: {e}")
|
|
||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
@@ -211,6 +233,7 @@ def process_video(path, *, maxsize, quality):
|
|||||||
assert isinstance(ostream, av.VideoStream)
|
assert isinstance(ostream, av.VideoStream)
|
||||||
ostream.width = frame.width
|
ostream.width = frame.width
|
||||||
ostream.height = frame.height
|
ostream.height = frame.height
|
||||||
|
ostream.pix_fmt = frame.format.name
|
||||||
icc = istream.codec_context
|
icc = istream.codec_context
|
||||||
occ = ostream.codec_context
|
occ = ostream.codec_context
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
class LRUCache:
|
class LRUCache:
|
||||||
@@ -12,7 +13,7 @@ class LRUCache:
|
|||||||
cache (list): Internal list storing the cache items.
|
cache (list): Internal list storing the cache items.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
def __init__(self, open: Callable, *, capacity: int, maxage: float):
|
||||||
"""
|
"""
|
||||||
Initialize LRUCache.
|
Initialize LRUCache.
|
||||||
|
|
||||||
@@ -50,7 +51,6 @@ class LRUCache:
|
|||||||
# Add/restore to end of cache
|
# Add/restore to end of cache
|
||||||
self.cache.insert(0, (key, f, monotonic()))
|
self.cache.insert(0, (key, f, monotonic()))
|
||||||
self.expire_items()
|
self.expire_items()
|
||||||
print(self.cache)
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
def expire_items(self):
|
def expire_items(self):
|
||||||
|
|||||||
+2
-2
@@ -440,14 +440,14 @@ def watcher_poll(loop):
|
|||||||
quit.wait(0.1 + 8 * dur)
|
quit.wait(0.1 + 8 * dur)
|
||||||
|
|
||||||
|
|
||||||
def start(app, loop):
|
def start(app):
|
||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
use_inotify = sys.platform == "linux"
|
use_inotify = sys.platform == "linux"
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher_inotify if use_inotify else watcher_poll,
|
target=watcher_inotify if use_inotify else watcher_poll,
|
||||||
args=[loop],
|
args=[app.loop],
|
||||||
# Descriptive name for system monitoring
|
# Descriptive name for system monitoring
|
||||||
name=f"cista-watcher {rootpath}",
|
name=f"cista-watcher {rootpath}",
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+6
@@ -1 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang=en>
|
<html lang=en>
|
||||||
<meta charset=UTF-8>
|
<meta charset=UTF-8>
|
||||||
<title>Cista Storage</title>
|
<title>Cista Storage</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
|
||||||
<link rel="icon" href="/src/assets/logo.svg">
|
<link rel="icon" href="/src/assets/logo.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
|
<UserManagementModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<HeaderSelected :path="path.pathList" />
|
||||||
@@ -28,6 +29,7 @@ import { computed } from 'vue'
|
|||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
margin: 0 .5rem 0 1rem !important;
|
margin: 0 .5rem 0 1rem !important;
|
||||||
}
|
}
|
||||||
body#app {
|
body#app {
|
||||||
|
position: static !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
@@ -165,6 +166,11 @@ body {
|
|||||||
font-family: 'Roboto';
|
font-family: 'Roboto';
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
/* Prevent any scrolling on body */
|
||||||
|
overflow: hidden;
|
||||||
|
/* Fallback for older browsers */
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
tbody .size,
|
tbody .size,
|
||||||
tbody .modified {
|
tbody .modified {
|
||||||
@@ -214,12 +220,14 @@ table {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
body#app {
|
body#app {
|
||||||
height: 100vh;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0; /* Allow flex child to shrink below content size */
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -237,6 +245,7 @@ header nav.headermain {
|
|||||||
z-index: 101;
|
z-index: 101;
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
@@ -248,9 +257,6 @@ header nav.headermain {
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
|
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
|
||||||
}
|
}
|
||||||
.modified [data-tooltip]:hover:after {
|
|
||||||
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
|
|
||||||
}
|
|
||||||
@keyframes appearbriefly {
|
@keyframes appearbriefly {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Global activation state - shared across all instances
|
||||||
|
let globalActive = false
|
||||||
|
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
text: string
|
||||||
|
delay?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const mouseX = ref(0)
|
||||||
|
const mouseY = ref(0)
|
||||||
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const tooltipStyle = computed(() => ({
|
||||||
|
left: `${mouseX.value + 12}px`,
|
||||||
|
top: `${mouseY.value + 12}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const startHover = (e: MouseEvent) => {
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
// Clear any pending deactivation
|
||||||
|
if (globalDeactivateTimer) {
|
||||||
|
clearTimeout(globalDeactivateTimer)
|
||||||
|
globalDeactivateTimer = null
|
||||||
|
}
|
||||||
|
const delay = globalActive ? 0 : (props.delay ?? 800)
|
||||||
|
hoverTimer = setTimeout(() => {
|
||||||
|
visible.value = true
|
||||||
|
globalActive = true
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = (e: MouseEvent) => {
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
const endHover = () => {
|
||||||
|
if (hoverTimer) {
|
||||||
|
clearTimeout(hoverTimer)
|
||||||
|
hoverTimer = null
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
// Deactivate global state after a short delay if no new tooltip started
|
||||||
|
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
|
||||||
|
globalDeactivateTimer = setTimeout(() => {
|
||||||
|
globalActive = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
startHover,
|
||||||
|
updatePosition,
|
||||||
|
endHover,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cursor-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border-radius: 3rem 0 3rem 0;
|
||||||
|
box-shadow: 0 0 1rem var(--accent-color);
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create directory', hdir, error)
|
console.error('Failed to create directory', hdir, error)
|
||||||
return
|
throw new Error(`Failed to create directory ${hdir}: ${error}`)
|
||||||
}
|
}
|
||||||
console.log('Created', hdir)
|
console.log('Created', hdir)
|
||||||
}
|
}
|
||||||
@@ -90,37 +90,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
fileHandle = await h.getFileHandle(name, { create: true })
|
fileHandle = await h.getFileHandle(name, { create: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||||
return
|
throw new Error(`Failed to create file ${hdir + name}: ${error}`)
|
||||||
}
|
}
|
||||||
const writable = await fileHandle.createWritable()
|
try {
|
||||||
const url = `/files/${rel}`
|
const writable = await fileHandle.createWritable()
|
||||||
console.log('Fetching', url)
|
const url = `/files/${rel}`
|
||||||
const res = await fetch(url)
|
console.log('Fetching', url)
|
||||||
if (!res.ok) {
|
const res = await fetch(url)
|
||||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||||
}
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||||
if (res.body) {
|
|
||||||
++store.dprogress.fileidx
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
await writable.truncate(0)
|
|
||||||
store.error = "Direct download."
|
|
||||||
store.dprogress.tlast = Date.now()
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
await writable.write(value)
|
|
||||||
const now = Date.now()
|
|
||||||
const size = value.byteLength
|
|
||||||
store.dprogress.xfer += size
|
|
||||||
store.dprogress.filepos += size
|
|
||||||
store.dprogress.statbytes += size
|
|
||||||
store.dprogress.statdur += now - store.dprogress.tlast
|
|
||||||
store.dprogress.tlast = now
|
|
||||||
}
|
}
|
||||||
|
if (res.body) {
|
||||||
|
++store.dprogress.fileidx
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
await writable.truncate(0)
|
||||||
|
store.error = "Direct download."
|
||||||
|
store.dprogress.tlast = Date.now()
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
await writable.write(value)
|
||||||
|
const now = Date.now()
|
||||||
|
const size = value.byteLength
|
||||||
|
store.dprogress.xfer += size
|
||||||
|
store.dprogress.filepos += size
|
||||||
|
store.dprogress.statbytes += size
|
||||||
|
store.dprogress.statdur += now - store.dprogress.tlast
|
||||||
|
store.dprogress.tlast = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writable.close()
|
||||||
|
console.log('Saved', hdir + name)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write file', hdir + name, error)
|
||||||
|
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
|
||||||
}
|
}
|
||||||
await writable.close()
|
|
||||||
console.log('Saved', hdir + name)
|
|
||||||
}
|
}
|
||||||
statReset()
|
statReset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import cog from '@/assets/svg/cog.svg'
|
import cog from '@/assets/svg/cog.svg'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<td class="name">
|
<td class="name">
|
||||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=editing :key=nowkey />
|
<FileModified :doc=editing :now=nowkey />
|
||||||
<FileSize :doc=editing />
|
<FileSize :doc=editing />
|
||||||
<td class="menu"></td>
|
<td class="menu"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=doc :key=nowkey />
|
<FileModified :doc=doc :now=nowkey />
|
||||||
<FileSize :doc=doc />
|
<FileSize :doc=doc />
|
||||||
<td class="menu">
|
<td class="menu">
|
||||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<td class="modified right">
|
<td class="modified right">
|
||||||
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time>
|
<time
|
||||||
|
:datetime=datetime
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
|
>{{ modified }}</time>
|
||||||
|
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { formatUnixDate } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
now: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
// Reference props.now to trigger reactivity when time updates
|
||||||
|
const modified = computed(() => {
|
||||||
|
props.now // trigger reactivity
|
||||||
|
return formatUnixDate(props.doc.mtime)
|
||||||
|
})
|
||||||
|
|
||||||
const datetime = computed(() =>
|
const datetime = computed(() =>
|
||||||
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
||||||
)
|
)
|
||||||
|
|
||||||
const tooltip = computed(() =>
|
const tooltipText = computed(() =>
|
||||||
datetime.value.replace('T', '\n').replace('Z', ' UTC')
|
datetime.value.replace('T', ' ').replace('Z', ' UTC')
|
||||||
)
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
doc: Doc
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)">
|
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||||
<template v-if=showFolderBreadcrumb(index)>
|
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
|
||||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
</template>
|
|
||||||
</GalleryFigure>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -55,10 +51,12 @@ const rename = (doc: Doc, newName: string) => {
|
|||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
}
|
}
|
||||||
const gallery = ref<HTMLElement>()
|
const gallery = ref<HTMLElement>()
|
||||||
const columns = computed(() => {
|
const columnCount = ref(1)
|
||||||
if (!gallery.value) return 1
|
const updateColumns = () => {
|
||||||
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
if (!gallery.value) return
|
||||||
})
|
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||||
|
}
|
||||||
|
const columns = computed(() => columnCount.value)
|
||||||
defineExpose({
|
defineExpose({
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -168,12 +166,21 @@ watchEffect(() => {
|
|||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||||
active.focus()
|
active.focus()
|
||||||
}
|
}
|
||||||
|
updateColumns()
|
||||||
|
if (gallery.value) {
|
||||||
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
|
resizeObserver.observe(gallery.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
const mkdir = (doc: Doc, name: string) => {
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
@@ -205,6 +212,8 @@ const showFolderBreadcrumb = (i: number) => {
|
|||||||
const docloc = docs[i].loc
|
const docloc = docs[i].loc
|
||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const selectionIndeterminate = computed({
|
const selectionIndeterminate = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return (
|
return (
|
||||||
@@ -254,13 +263,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
grid-template-rows: repeat(minmax(auto, 15em));
|
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.breadcrumb {
|
.folder-indicator {
|
||||||
border-radius: .5em 0 0 .5em;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.spacer {
|
.folder-start {
|
||||||
flex: 0 1000000000 4rem;
|
grid-column-start: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
@focus.stop="store.cursor = doc.key"
|
@focus.stop="store.cursor = doc.key"
|
||||||
@click=onclick
|
@click=onclick
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
@@ -15,19 +18,24 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
<span>{{ doc.name }}</span>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
|
<div class="tooltip-name">{{ doc.name }}</div>
|
||||||
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
|
</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang=ts>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
@@ -40,6 +48,9 @@ const props = defineProps<{
|
|||||||
editing?: EditingProp,
|
editing?: EditingProp,
|
||||||
}>()
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const tooltipText = computed(() => props.doc.key)
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
@@ -48,6 +59,13 @@ const onclick = (ev: Event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.tooltip-name {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tooltip-details {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
max-height: 15em;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -57,12 +75,15 @@ figure {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
figure > article {
|
figure > article {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
figure :deep(.video-container) {
|
||||||
|
height: 15em;
|
||||||
|
}
|
||||||
.titlespacer {
|
.titlespacer {
|
||||||
flex-shrink: 100000;
|
flex-shrink: 100000;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ watchEffect(() => {
|
|||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
|
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||||
|
if (store.user.privileged) {
|
||||||
|
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||||
|
}
|
||||||
if (store.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
<img v-else-if=doc.img :src=doc.url alt="">
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
<div v-else-if=video() class="video-container">
|
||||||
|
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
|
<div class="play-overlay"><PlayIcon /></div>
|
||||||
|
</div>
|
||||||
<div v-else-if=audio() class="audio icon">
|
<div v-else-if=audio() class="audio icon">
|
||||||
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,6 +16,7 @@
|
|||||||
<script setup lang=ts>
|
<script setup lang=ts>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import PlayIcon from '@/assets/svg/play.svg'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
@@ -165,4 +169,43 @@ img::before {
|
|||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
content: '❌';
|
content: '❌';
|
||||||
}
|
}
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 50%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.video-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(.5em / 8);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 4em;
|
||||||
|
height: 4em;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.play-overlay svg {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
fill: white;
|
||||||
|
margin-left: 0.25em; /* Visual centering for play triangle */
|
||||||
|
}
|
||||||
|
.video-container:hover .play-overlay {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
video[data-playing] + .play-overlay {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name=usermgmt title="Admin Settings">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<h3>Server Settings</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<input
|
||||||
|
id="publicServer"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="serverSettings.public"
|
||||||
|
@change="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<label for="publicServer">Publicly accessible without any user account.</label>
|
||||||
|
</div>
|
||||||
|
<h3>Users</h3>
|
||||||
|
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||||
|
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
||||||
|
{{ success }}
|
||||||
|
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
||||||
|
</div>
|
||||||
|
<table class="user-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.username">
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="user.privileged"
|
||||||
|
@change="toggleAdmin(user, $event)"
|
||||||
|
:disabled="user.username === store.user.username"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button @click="renameUser(user)" class="button small" title="Rename user">✏️</button>
|
||||||
|
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
|
||||||
|
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="close" class="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
username: string
|
||||||
|
privileged: boolean
|
||||||
|
lastSeen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const copyButtonText = ref('📋')
|
||||||
|
const serverSettings = reactive({
|
||||||
|
public: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const data = await listUsers()
|
||||||
|
users.value = data.users
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to load users'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addUser = async () => {
|
||||||
|
const username = window.prompt('Enter username for new user:')
|
||||||
|
if (!username || !username.trim()) return
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const result = await createUser(username.trim(), undefined, false)
|
||||||
|
await loadUsers()
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `User ${username.trim()} created. Password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to add user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAdmin = async (user: User, event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
await updateUser(user.username, { privileged: target.checked })
|
||||||
|
user.privileged = target.checked
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to update user'
|
||||||
|
target.checked = user.privileged // revert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameUser = async (user: User) => {
|
||||||
|
const newName = window.prompt('Enter new username:', user.username)
|
||||||
|
if (!newName || !newName.trim() || newName.trim() === user.username) return
|
||||||
|
// For rename, we need to create new user and delete old, or have a rename endpoint
|
||||||
|
// Since no rename endpoint, perhaps delete and create
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||||||
|
await deleteUser(user.username)
|
||||||
|
await loadUsers()
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to rename user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPassword = async (user: User) => {
|
||||||
|
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const result = await updateUser(user.username, { password: "" })
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to reset password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUserAction = async (username: string) => {
|
||||||
|
if (!confirm(`Delete user ${username}?`)) return
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
await deleteUser(username)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to delete user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||||
|
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||||
|
if (passwordMatch) {
|
||||||
|
await navigator.clipboard.writeText(passwordMatch[1])
|
||||||
|
if (isButtonClick) {
|
||||||
|
// Show "Copied!" indication on button
|
||||||
|
copyButtonText.value = '✅ Copied!'
|
||||||
|
// Hide password and button immediately after copying
|
||||||
|
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
|
||||||
|
success.value = baseMessage
|
||||||
|
// Hide the entire message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
success.value = ''
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
// Just hide the message when clicking elsewhere
|
||||||
|
success.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServerSettings = async () => {
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
await updatePublic(serverSettings.public)
|
||||||
|
// Update store
|
||||||
|
store.server.public = serverSettings.public
|
||||||
|
success.value = 'Server settings updated'
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
error.value = httpError.message || 'Failed to update settings'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
serverSettings.public = store.server.public
|
||||||
|
loadUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => store.server.public, (newVal) => {
|
||||||
|
serverSettings.public = newVal
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.user-table th, .user-table td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.user-table th {
|
||||||
|
background: var(--soft-color);
|
||||||
|
}
|
||||||
|
.button.small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.button.danger {
|
||||||
|
background: var(--red-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.button.danger:hover {
|
||||||
|
background: #d00;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-row label {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.success-message {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
class ClientClass {
|
class ClientClass {
|
||||||
|
async get(url: string): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -17,6 +33,40 @@ class ClientClass {
|
|||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
async put(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: data !== undefined ? JSON.stringify(data) : undefined
|
||||||
|
})
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
async delete(url: string): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Client = new ClientClass()
|
export const Client = new ClientClass()
|
||||||
|
|||||||
@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
|
|||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const url_users = '/users'
|
||||||
|
|
||||||
|
export async function listUsers() {
|
||||||
|
const data = await Client.get(url_users)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string, password?: string, privileged?: boolean) {
|
||||||
|
const data = await Client.post(url_users, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privileged
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
|
||||||
|
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(username: string) {
|
||||||
|
const data = await Client.delete(`${url_users}/${username}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePublic(publicFlag: boolean) {
|
||||||
|
const data = await Client.put('/config/public', { public: publicFlag })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const useMainStore = defineStore({
|
|||||||
connected: false,
|
connected: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any>,
|
||||||
dialog: '' as '' | 'login' | 'settings',
|
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
|
|||||||
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
|
|||||||
sorted.sort(ordering[order])
|
sorted.sort(ordering[order])
|
||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort documents while keeping files grouped by their folder.
|
||||||
|
* - name: folders sorted by folder path, items within by name
|
||||||
|
* - modified: folders sorted by newest item within results, items within by mtime
|
||||||
|
* - size: folders sorted by largest file within results, items within by size
|
||||||
|
*/
|
||||||
|
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
|
||||||
|
if (!order) return documents
|
||||||
|
|
||||||
|
const compare = ordering[order]
|
||||||
|
|
||||||
|
// Group documents by their folder location
|
||||||
|
const byFolder = new Map<string, Doc[]>()
|
||||||
|
for (const doc of documents) {
|
||||||
|
const folder = doc.loc
|
||||||
|
if (!byFolder.has(folder)) byFolder.set(folder, [])
|
||||||
|
byFolder.get(folder)!.push(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items within each folder
|
||||||
|
for (const docs of byFolder.values()) {
|
||||||
|
docs.sort(compare)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the "best" item in each folder (first after sorting = best according to criteria)
|
||||||
|
const folderBest = new Map<string, Doc>()
|
||||||
|
for (const [folder, docs] of byFolder) {
|
||||||
|
folderBest.set(folder, docs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort folders: by path for name sort, by best item for modified/size
|
||||||
|
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
|
||||||
|
if (order === 'name') {
|
||||||
|
return collator.compare(a, b)
|
||||||
|
}
|
||||||
|
return compare(folderBest.get(a)!, folderBest.get(b)!)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flatten back into a single array with folder grouping preserved
|
||||||
|
const result: Doc[] = []
|
||||||
|
for (const folder of sortedFolders) {
|
||||||
|
result.push(...byFolder.get(folder)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { watchEffect, ref, computed, watch } from 'vue'
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
import { needleFormat, localeIncludes, collator } from '@/utils'
|
||||||
import { sorted } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -49,9 +49,9 @@ const documents = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const locsub = loc + '/'
|
const locsub = loc + '/'
|
||||||
// Custom sort override in effect?
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
if (order) return sorted(docs, order)
|
if (order) return sortedGrouped(docs, order)
|
||||||
// Sort by relevance - current folder, then subfolders, then others
|
// Sort by relevance - current folder, then subfolders, then others
|
||||||
docs.sort((a, b) => (
|
docs.sort((a, b) => (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -73,8 +73,11 @@ watchEffect(() => {
|
|||||||
store.query = props.query
|
store.query = props.query
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(documents, (docs) => {
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
store.prefs.gallery = docs.some(d => d.previewable)
|
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
|
||||||
|
// React to path change or initial document load (0 → non-zero)
|
||||||
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
|
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
|
||||||
// Development mode:
|
|
||||||
// bun run dev # Run frontend that proxies to dev_backend
|
|
||||||
// cista -l :8000 --dev # Run backend
|
|
||||||
const dev_backend = {
|
const dev_backend = {
|
||||||
target: "http://localhost:8000",
|
target: process.env.CISTA_BACKEND_URL || "http://localhost:8989",
|
||||||
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
||||||
ws: true,
|
ws: true,
|
||||||
}
|
}
|
||||||
@@ -48,7 +45,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: "../cista/wwwroot",
|
outDir: "../cista/frontend-build",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-4
@@ -27,7 +27,6 @@ dependencies = [
|
|||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"av>=15.0.0",
|
"av>=15.0.0",
|
||||||
"blake3>=1.0.5",
|
"blake3>=1.0.5",
|
||||||
"brotli>=1.1.0",
|
|
||||||
"docopt>=0.6.2",
|
"docopt>=0.6.2",
|
||||||
"inotify>=0.2.12",
|
"inotify>=0.2.12",
|
||||||
"msgspec>=0.19.0",
|
"msgspec>=0.19.0",
|
||||||
@@ -38,10 +37,11 @@ dependencies = [
|
|||||||
"pillow-heif>=1.1.0",
|
"pillow-heif>=1.1.0",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"pymupdf>=1.26.3",
|
"pymupdf>=1.26.3",
|
||||||
"sanic>=25.3.0",
|
"sanic>=25.12.0",
|
||||||
"setproctitle>=1.3.6",
|
"setproctitle>=1.3.6",
|
||||||
"stream-zip>=0.0.83",
|
"stream-zip>=0.0.83",
|
||||||
"tomli_w>=1.2.0",
|
"tomli_w>=1.2.0",
|
||||||
|
"zstandard>=0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -71,8 +71,8 @@ docs = [
|
|||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["cista/wwwroot"]
|
artifacts = ["cista/frontend-build"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
|
||||||
targets.sdist.include = [
|
targets.sdist.include = [
|
||||||
"/cista",
|
"/cista",
|
||||||
]
|
]
|
||||||
@@ -82,6 +82,7 @@ hooks.vcs.template = """
|
|||||||
__version__ = {version!r}
|
__version__ = {version!r}
|
||||||
"""
|
"""
|
||||||
only-packages = true
|
only-packages = true
|
||||||
|
packages = ["cista"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = [
|
addopts = [
|
||||||
@@ -119,6 +120,7 @@ dev = [
|
|||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"pre-commit>=4.0.0",
|
"pre-commit>=4.0.0",
|
||||||
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
"pytest>=8.4.1",
|
"pytest>=8.4.1",
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# noqa: INP001
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from sys import stderr
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
|
||||||
def initialize(self, version, build_data):
|
|
||||||
super().initialize(version, build_data)
|
|
||||||
stderr.write(">>> Building Cista frontend\n")
|
|
||||||
npm = None
|
|
||||||
bun = shutil.which("bun")
|
|
||||||
if bun is None:
|
|
||||||
npm = shutil.which("npm")
|
|
||||||
if npm is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Bun or NodeJS `npm` is required for building but neither was found\n Visit https://bun.com/"
|
|
||||||
)
|
|
||||||
# npm --prefix doesn't work on Windows, so we chdir instead
|
|
||||||
os.chdir("frontend")
|
|
||||||
try:
|
|
||||||
if npm:
|
|
||||||
stderr.write("### npm install\n")
|
|
||||||
subprocess.run([npm, "install"], check=True) # noqa: S603
|
|
||||||
stderr.write("\n### npm run build\n")
|
|
||||||
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
|
|
||||||
else:
|
|
||||||
assert bun
|
|
||||||
stderr.write("### bun install\n")
|
|
||||||
subprocess.run([bun, "install"], check=True) # noqa: S603
|
|
||||||
stderr.write("\n### bun run build\n")
|
|
||||||
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
|
|
||||||
finally:
|
|
||||||
os.chdir("..")
|
|
||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run scripts/devserver.py [-l <listen>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-l LISTEN Listen address for backend (default: from config, or :8000)
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
from cista.serve import parse_listen
|
||||||
|
|
||||||
|
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = 5173
|
||||||
|
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||||
|
|
||||||
|
BUN_BUG = """\
|
||||||
|
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||||
|
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||||
|
┃
|
||||||
|
┃ Consider using deno or npm instead for development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_frontend_tools(vite_port: int) -> tuple[list[str], list[str], str]:
|
||||||
|
"""Resolve frontend install and dev commands.
|
||||||
|
|
||||||
|
Returns (install_cmd, dev_cmd, tool_name).
|
||||||
|
Raises SystemExit if tools are not available.
|
||||||
|
"""
|
||||||
|
if not (FRONTEND_PATH / "package.json").exists():
|
||||||
|
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
||||||
|
dev_cmd, name = find_dev_tool() # noqa # type: ignore
|
||||||
|
if dev_cmd is None:
|
||||||
|
if not os.environ.get("JS_RUNTIME"):
|
||||||
|
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
dev_cmd = [*dev_cmd, "--clearScreen=false", f"--port={vite_port}"]
|
||||||
|
|
||||||
|
if name == "bun":
|
||||||
|
stderr.write(BUN_BUG)
|
||||||
|
|
||||||
|
return install_cmd, dev_cmd, name
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_backend(host: str, port: int):
|
||||||
|
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||||
|
max_attempts = 50
|
||||||
|
url = f"http://{host}:{port}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
await client.get(url, timeout=1.0)
|
||||||
|
stderr.write("✓ Backend ready!\n")
|
||||||
|
return True
|
||||||
|
except httpx.RequestError:
|
||||||
|
if attempt == max_attempts - 1:
|
||||||
|
stderr.write("┃ ⚠️ Backend didn't start in time\n")
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
|
||||||
|
"""Gracefully terminate a subprocess."""
|
||||||
|
if proc.returncode is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||||
|
except TimeoutError:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_devserver(backend_port: int, cista_args: list[str]) -> None:
|
||||||
|
"""Run the development server with install, backend, and frontend."""
|
||||||
|
vite_port = DEFAULT_VITE_PORT
|
||||||
|
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port)
|
||||||
|
|
||||||
|
# Tell the backend where the Vite dev server is (not used yet)
|
||||||
|
os.environ["CISTA_DEV_FRONTEND_URL"] = f"http://localhost:{vite_port}"
|
||||||
|
|
||||||
|
backend_cmd = ["cista", "--dev", *cista_args]
|
||||||
|
|
||||||
|
cwd = str(Path(__file__).parent.parent)
|
||||||
|
frontend_cwd = str(FRONTEND_PATH)
|
||||||
|
|
||||||
|
backend_proc: asyncio.subprocess.Process | None = None
|
||||||
|
install_proc: asyncio.subprocess.Process | None = None
|
||||||
|
frontend_proc: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start install (concurrent with backend)
|
||||||
|
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
|
||||||
|
install_proc = await asyncio.create_subprocess_exec(
|
||||||
|
*install_cmd, cwd=frontend_cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Start backend (concurrent with install)
|
||||||
|
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||||
|
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
||||||
|
|
||||||
|
# Wait for install to complete and backend to be ready
|
||||||
|
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
||||||
|
backend_ready_task = asyncio.create_task(
|
||||||
|
wait_for_backend("localhost", backend_port), name="backend_ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{install_task, backend_ready_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in done:
|
||||||
|
if task.get_name() == "install":
|
||||||
|
if task.result() != 0:
|
||||||
|
stderr.write("┃ ⚠️ Install failed\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
elif task.get_name() == "backend_ready" and not task.result():
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
done2, _ = await asyncio.wait(pending)
|
||||||
|
for task in done2:
|
||||||
|
if task.get_name() == "install":
|
||||||
|
if task.result() != 0:
|
||||||
|
stderr.write("┃ ⚠️ Install failed\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
elif task.get_name() == "backend_ready" and not task.result():
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
install_proc = None
|
||||||
|
|
||||||
|
# Start Vite dev server
|
||||||
|
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
|
||||||
|
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
|
||||||
|
|
||||||
|
# Wait for either process to exit
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{
|
||||||
|
asyncio.create_task(backend_proc.wait(), name="backend"),
|
||||||
|
asyncio.create_task(frontend_proc.wait(), name="frontend"),
|
||||||
|
},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
for t in done:
|
||||||
|
t.result()
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
stderr.write("\n✓ Shutting down...\n")
|
||||||
|
finally:
|
||||||
|
if frontend_proc is not None:
|
||||||
|
await _terminate_process(frontend_proc, "frontend")
|
||||||
|
if install_proc is not None:
|
||||||
|
await _terminate_process(install_proc, "install")
|
||||||
|
if backend_proc is not None:
|
||||||
|
await _terminate_process(backend_proc, "backend")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Pass all arguments to cista, parse -l to determine backend port
|
||||||
|
cista_args = sys.argv[1:]
|
||||||
|
listen_arg = None
|
||||||
|
if "-l" in cista_args:
|
||||||
|
idx = cista_args.index("-l")
|
||||||
|
if idx + 1 < len(cista_args):
|
||||||
|
listen_arg = cista_args[idx + 1]
|
||||||
|
|
||||||
|
# Load config to get the backend port
|
||||||
|
config.load_config()
|
||||||
|
listen = listen_arg or config.config.listen or ":8000"
|
||||||
|
_, opts = parse_listen(listen)
|
||||||
|
backend_port = opts.get("port", 8000)
|
||||||
|
|
||||||
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
|
asyncio.run(run_devserver(backend_port, cista_args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||||
|
|
||||||
|
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kwargs):
|
||||||
|
"""Run a command and display it."""
|
||||||
|
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||||
|
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||||
|
subprocess.run(cmd, check=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface):
|
||||||
|
"""Build hook that compiles Vue frontend before packaging."""
|
||||||
|
|
||||||
|
def initialize(self, version, build_data):
|
||||||
|
super().initialize(version, build_data)
|
||||||
|
stderr.write(">>> Building the frontend\n")
|
||||||
|
|
||||||
|
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(install_cmd, cwd="frontend")
|
||||||
|
stderr.write("\n")
|
||||||
|
run(build_cmd, cwd="frontend")
|
||||||
|
except Exception as e:
|
||||||
|
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared utilities for build and dev scripts."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
|
||||||
|
def find_js_runtime() -> tuple[str, str] | None:
|
||||||
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
|
Returns None if no runtime is found.
|
||||||
|
"""
|
||||||
|
options = ["deno", "npm", "bun"]
|
||||||
|
|
||||||
|
# Check for JS_RUNTIME environment variable
|
||||||
|
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||||
|
js_runtime = js_runtime_env
|
||||||
|
js_path = Path(js_runtime)
|
||||||
|
runtime_name = js_path.name
|
||||||
|
# Map node to npm
|
||||||
|
if runtime_name == "node":
|
||||||
|
runtime_name = "npm"
|
||||||
|
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||||
|
for option in options:
|
||||||
|
if option == runtime_name or runtime_name.startswith(option):
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
|
||||||
|
return None
|
||||||
|
return tool, option
|
||||||
|
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Auto-detect
|
||||||
|
for option in options:
|
||||||
|
if tool := shutil.which(option):
|
||||||
|
return tool, option
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_build_tool():
|
||||||
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install = {
|
||||||
|
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install",),
|
||||||
|
"bun": ("--bun", "install"),
|
||||||
|
}
|
||||||
|
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||||
|
build = {
|
||||||
|
"deno": ("run", "-A", "npm:vite", "build"),
|
||||||
|
"npm": ("run", "build"),
|
||||||
|
"bun": ("--bun", "run", "build"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = find_js_runtime()
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Deno, npm or Bun is required for building but none was found"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool, name = result
|
||||||
|
return [tool, *install[name]], [tool, *build[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_dev_tool():
|
||||||
|
"""Find JavaScript runtime and construct dev command.
|
||||||
|
|
||||||
|
Returns (dev_cmd, tool_name) or (None, None) if not found.
|
||||||
|
"""
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "dev", "--"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = find_js_runtime()
|
||||||
|
if result is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
tool, name = result
|
||||||
|
return [tool, *dev_args[name]], name
|
||||||
Reference in New Issue
Block a user