Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94ccc01c6 | ||
|
|
bc355d991d | ||
|
|
e7ebad3c36 | ||
|
|
036c4342de | ||
|
|
160f929e0c | ||
|
|
1970d40d8a | ||
|
|
1a164e0a08 | ||
|
|
46d222006a | ||
|
|
98949e6b30 | ||
|
|
8f76d770ee | ||
|
|
232fd92b22 | ||
|
|
be69164c8f | ||
|
|
4f39875786 | ||
|
|
21250a1a2d | ||
|
|
849b1a6868 | ||
|
|
7be02e951d | ||
|
|
bb38328c24 |
@@ -38,6 +38,39 @@ pip install cista --break-system-packages
|
|||||||
|
|
||||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Cista supports three authentication modes:
|
||||||
|
|
||||||
|
### Built-in Authentication (default)
|
||||||
|
|
||||||
|
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
uvx cista --user admin --privileged # Create admin user
|
||||||
|
uvx cista --user guest # Create regular user
|
||||||
|
```
|
||||||
|
|
||||||
|
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||||
|
|
||||||
|
### Public Mode
|
||||||
|
|
||||||
|
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||||
|
|
||||||
|
### Paskia SSO Authentication
|
||||||
|
|
||||||
|
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||||
|
```
|
||||||
|
|
||||||
|
In Paskia mode:
|
||||||
|
- All `/auth/*` requests are proxied to the Paskia backend
|
||||||
|
- Users with `cista:login` permission can access files
|
||||||
|
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||||
|
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
||||||
|
|
||||||
### Internet Access
|
### Internet Access
|
||||||
|
|
||||||
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
||||||
|
|||||||
+43
-14
@@ -25,6 +25,28 @@ def create_banner():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
||||||
|
"""Create a framed startup box with server information."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
listen = unix if unix else url
|
||||||
|
location = f"{folder} @ {listen}"
|
||||||
|
lines = [title, location]
|
||||||
|
if paskia_url:
|
||||||
|
lines.append(f"Paskia: {paskia_url}")
|
||||||
|
if dev:
|
||||||
|
lines.append("dev mode")
|
||||||
|
|
||||||
|
# Calculate width based on content
|
||||||
|
inner_width = max(len(line) for line in lines) + 2
|
||||||
|
|
||||||
|
# Build the box
|
||||||
|
box = [f"╭{'─' * inner_width}╮"]
|
||||||
|
for line in lines:
|
||||||
|
box.append(f"│ {line:<{inner_width - 1}}│")
|
||||||
|
box.append(f"╰{'─' * inner_width}╯")
|
||||||
|
return "\n".join(box) + "\n"
|
||||||
|
|
||||||
|
|
||||||
banner = create_banner()
|
banner = create_banner()
|
||||||
|
|
||||||
doc = """\
|
doc = """\
|
||||||
@@ -42,13 +64,17 @@ Options:
|
|||||||
--import-droppy Import Droppy config from ~/.droppy/config
|
--import-droppy Import Droppy config from ~/.droppy/config
|
||||||
--dev Developer mode (reloads, friendlier crashes, more logs)
|
--dev Developer mode (reloads, friendlier crashes, more logs)
|
||||||
|
|
||||||
Listen address, path and imported options are preserved in config, and only
|
Listen address and path are preserved in config,
|
||||||
custom config dir and dev mode need to be specified on subsequent runs.
|
and only config dir and dev mode need to be specified on subsequent runs.
|
||||||
|
|
||||||
User management:
|
User management:
|
||||||
--user NAME Create or modify user
|
--user NAME Create or modify user
|
||||||
--privileged Give the user full admin rights
|
--privileged Give the user full admin rights
|
||||||
--password Reset password
|
--password Reset password
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
||||||
|
https://git.zi.fi/leovasanko/paskia
|
||||||
"""
|
"""
|
||||||
|
|
||||||
first_time_help = """\
|
first_time_help = """\
|
||||||
@@ -79,8 +105,7 @@ def _main():
|
|||||||
elif "--version" in sys.argv:
|
elif "--version" in sys.argv:
|
||||||
sys.stdout.write(f"cista {cista.__version__}\n")
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
return 0
|
return 0
|
||||||
else:
|
# Don't print banner yet for normal startup - we'll print the startup box later
|
||||||
sys.stderr.write(banner)
|
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
@@ -107,6 +132,7 @@ def _main():
|
|||||||
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
||||||
)
|
)
|
||||||
settings = droppy.readconf()
|
settings = droppy.readconf()
|
||||||
|
# Droppy's public flag is kept as-is (same name in our config)
|
||||||
if path:
|
if path:
|
||||||
settings["path"] = path
|
settings["path"] = path
|
||||||
elif not exists:
|
elif not exists:
|
||||||
@@ -115,21 +141,24 @@ def _main():
|
|||||||
settings["listen"] = listen
|
settings["listen"] = listen
|
||||||
elif not exists:
|
elif not exists:
|
||||||
settings["listen"] = ":8000"
|
settings["listen"] = ":8000"
|
||||||
if not exists and not import_droppy:
|
|
||||||
# We have no users, so make it public
|
|
||||||
settings["public"] = True
|
|
||||||
operation = config.update_config(settings)
|
operation = config.update_config(settings)
|
||||||
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
unix = None
|
url, opts = serve.parse_listen(config.config.listen)
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
|
||||||
if not config.config.path.is_dir():
|
if not config.config.path.is_dir():
|
||||||
raise ValueError(f"No such directory: {config.config.path}")
|
raise ValueError(f"No such directory: {config.config.path}")
|
||||||
extra = f" ({unix})" if unix else ""
|
|
||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
# Check for Paskia SSO
|
||||||
extra += " (dev mode)"
|
from cista.sso import PASKIA_BACKEND_URL
|
||||||
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
|
|
||||||
|
# Print startup box
|
||||||
|
startup_box = create_startup_box(
|
||||||
|
folder=config.config.path,
|
||||||
|
url=url,
|
||||||
|
unix=opts.get("unix"),
|
||||||
|
dev=dev,
|
||||||
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
)
|
||||||
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+42
-8
@@ -3,9 +3,10 @@ import typing
|
|||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint, json
|
||||||
|
from sanic.exceptions import BadRequest
|
||||||
|
|
||||||
from cista import __version__, config, watching
|
from cista import __version__, auth, config, sso, watching
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
from cista.util.apphelpers import asend, websocket_wrapper
|
||||||
@@ -92,6 +93,28 @@ async def control(req, ws):
|
|||||||
@bp.websocket("watch")
|
@bp.websocket("watch")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
|
# Build user info from either built-in auth or SSO
|
||||||
|
user_info = None
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||||
|
try:
|
||||||
|
await sso.validate_sso_request(req)
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore auth errors, user_info stays None
|
||||||
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||||
|
ctx = sso_user.get("ctx", {})
|
||||||
|
perms = ctx.get("permissions", [])
|
||||||
|
user_info = {
|
||||||
|
"username": ctx.get("user", {}).get("display_name", ""),
|
||||||
|
"privileged": "cista:admin" in perms,
|
||||||
|
}
|
||||||
|
elif req.ctx.user:
|
||||||
|
# Built-in auth: use local user database
|
||||||
|
user_info = {
|
||||||
|
"username": req.ctx.username,
|
||||||
|
"privileged": req.ctx.user.privileged,
|
||||||
|
}
|
||||||
|
|
||||||
await ws.send(
|
await ws.send(
|
||||||
msgspec.json.encode(
|
msgspec.json.encode(
|
||||||
{
|
{
|
||||||
@@ -99,13 +122,9 @@ async def watch(req, ws):
|
|||||||
"name": config.config.name or config.config.path.name,
|
"name": config.config.name or config.config.path.name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"public": config.config.public,
|
"public": config.config.public,
|
||||||
|
"paskia": sso.paskia_enabled(),
|
||||||
},
|
},
|
||||||
"user": {
|
"user": user_info,
|
||||||
"username": req.ctx.username,
|
|
||||||
"privileged": req.ctx.user.privileged,
|
|
||||||
}
|
|
||||||
if req.ctx.user
|
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
).decode()
|
).decode()
|
||||||
)
|
)
|
||||||
@@ -136,3 +155,18 @@ def subscribe(uuid, ws):
|
|||||||
watching.format_space(watching.state.space),
|
watching.format_space(watching.state.space),
|
||||||
watching.format_root(watching.state.root),
|
watching.format_root(watching.state.root),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("config/public")
|
||||||
|
async def update_public(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
public = request.json["public"]
|
||||||
|
if not isinstance(public, bool):
|
||||||
|
raise ValueError("public must be a boolean")
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing public field") from None
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(str(e)) from None
|
||||||
|
config.update_config({"public": public})
|
||||||
|
return json({"message": "Public access setting updated", "public": public})
|
||||||
|
|||||||
+45
-9
@@ -18,7 +18,7 @@ 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 zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, watching
|
from cista import auth, config, preview, session, sso, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
@@ -26,7 +26,11 @@ from cista.util.apphelpers import handle_sanic_exception
|
|||||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
app.blueprint(auth.bp)
|
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
||||||
|
else:
|
||||||
|
app.blueprint(auth.bp) # Built-in auth routes
|
||||||
app.blueprint(preview.bp)
|
app.blueprint(preview.bp)
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
app.exception(Exception)(handle_sanic_exception)
|
app.exception(Exception)(handle_sanic_exception)
|
||||||
@@ -39,10 +43,13 @@ setproctitle("cista-main")
|
|||||||
async def main_start(app):
|
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()))
|
# Small pool for memory-intensive preview generation
|
||||||
|
preview_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=preview_workers, thread_name_prefix="cista-preview"
|
||||||
)
|
)
|
||||||
|
# Larger pool for long-running but low-memory zip operations
|
||||||
|
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||||
watching.start(app)
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +59,8 @@ async def main_stop(app):
|
|||||||
quit.set()
|
quit.set()
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
|
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||||
|
await sso.close_client()
|
||||||
logger.debug("Cista worker threads all finished")
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
@@ -74,10 +83,23 @@ async def use_session(req):
|
|||||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_response
|
||||||
|
async def forward_sso_cookies(req, res):
|
||||||
|
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||||
|
if cookies := getattr(req.ctx, "sso_cookies", None):
|
||||||
|
for cookie in cookies:
|
||||||
|
res.headers.add("set-cookie", cookie)
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
async def verify_fileserver(request):
|
||||||
|
"""Verify access to file server routes."""
|
||||||
|
await auth.verify(request)
|
||||||
|
|
||||||
bp.static(
|
bp.static(
|
||||||
"/files/",
|
"/files/",
|
||||||
config.config.path,
|
config.config.path,
|
||||||
@@ -211,7 +233,7 @@ async def wwwroot(req, path=""):
|
|||||||
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
||||||
async def favicon(req):
|
async def favicon(req):
|
||||||
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||||
return redirect("/assets/logo-97d1d7eb.svg", status=308)
|
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
||||||
|
|
||||||
|
|
||||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||||
@@ -239,6 +261,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
|||||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||||
async def zip_download(req, keys, zipfile, ext):
|
async def zip_download(req, keys, zipfile, ext):
|
||||||
"""Download a zip archive of the given keys"""
|
"""Download a zip archive of the given keys"""
|
||||||
|
await auth.verify(req)
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
wanted = set(keys.split("+"))
|
||||||
files = get_files(wanted)
|
files = get_files(wanted)
|
||||||
@@ -269,27 +292,40 @@ async def zip_download(req, keys, zipfile, ext):
|
|||||||
yield chunk
|
yield chunk
|
||||||
assert size == 0
|
assert size == 0
|
||||||
|
|
||||||
|
pending_put = None # Current queue.put future, can be cancelled
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
|
nonlocal pending_put
|
||||||
try:
|
try:
|
||||||
for chunk in stream_zip(local_files(files)):
|
for chunk in stream_zip(local_files(files)):
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
|
||||||
|
pending_put = future
|
||||||
|
future.result() # Blocks until queue has space
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("ZIP download cancelled by client disconnect")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error streaming ZIP")
|
logger.exception("Error streaming ZIP")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
pending_put = None
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||||
|
|
||||||
# Don't block the event loop: run in a thread
|
# Don't block the event loop: run in a thread (use larger zip pool)
|
||||||
queue = asyncio.Queue(maxsize=1)
|
queue = asyncio.Queue(maxsize=1)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
thread = loop.run_in_executor(app.ctx.zipexec, worker)
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
res = await req.respond(
|
res = await req.respond(
|
||||||
content_type="application/zip",
|
content_type="application/zip",
|
||||||
headers={"cache-control": "no-store"},
|
headers={"cache-control": "no-store"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
while chunk := await queue.get():
|
while chunk := await queue.get():
|
||||||
await res.send(chunk)
|
await res.send(chunk)
|
||||||
|
finally:
|
||||||
|
# Cancel any pending put to unblock and stop the worker
|
||||||
|
if pending_put:
|
||||||
|
pending_put.cancel()
|
||||||
|
|
||||||
await thread # If it raises, the response will fail download
|
await thread # If it raises, the response will fail download
|
||||||
|
|||||||
+251
-50
@@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
|||||||
from cista import config, session
|
from cista import config, session
|
||||||
from cista.util import pwgen
|
from cista.util import pwgen
|
||||||
|
|
||||||
|
_LOGIN_PAGE_CSS = """\
|
||||||
|
/* ===========================================
|
||||||
|
LOGIN PAGE STYLES
|
||||||
|
Must match ModalDialog.vue global styles.
|
||||||
|
=========================================== */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: #ddd;
|
||||||
|
color: #000;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0 1rem #0008;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
color: #444;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
.button-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #f80; }
|
||||||
|
button:disabled {
|
||||||
|
background: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #c00;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LOGIN_PAGE_JS = """\
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const usernameField = document.getElementById('username');
|
||||||
|
const passwordField = document.getElementById('password');
|
||||||
|
const isInIframe = window.parent !== window;
|
||||||
|
|
||||||
|
// Focus username field on load
|
||||||
|
usernameField.focus();
|
||||||
|
|
||||||
|
const showError = (msg) => {
|
||||||
|
error.textContent = msg;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Log in';
|
||||||
|
// Focus and select the relevant field
|
||||||
|
if (msg.toLowerCase().includes('password')) {
|
||||||
|
passwordField.focus();
|
||||||
|
passwordField.select();
|
||||||
|
} else {
|
||||||
|
usernameField.focus();
|
||||||
|
usernameField.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
error.textContent = '';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Logging in...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameField.value,
|
||||||
|
password: passwordField.value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (isInIframe) {
|
||||||
|
window.parent.postMessage({type: 'auth-success'}, '*');
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showError(data.message || data.detail || 'Login failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Connection error. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import for SSO validation (lazily loaded to avoid circular imports)
|
||||||
|
_sso_module = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sso():
|
||||||
|
global _sso_module
|
||||||
|
if _sso_module is None:
|
||||||
|
from cista import sso
|
||||||
|
|
||||||
|
_sso_module = sso
|
||||||
|
return _sso_module
|
||||||
|
|
||||||
|
|
||||||
_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})$")
|
||||||
|
|
||||||
@@ -63,62 +231,106 @@ class LoginResponse(msgspec.Struct):
|
|||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
def verify(request, *, privileged=False):
|
async def verify(request, *, privileged=False):
|
||||||
"""Raise Unauthorized or Forbidden if the request is not authorized"""
|
"""Verify that the request is authorized.
|
||||||
|
|
||||||
|
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
||||||
|
For built-in mode, checks session-based authentication.
|
||||||
|
For public mode (config.public=True), skips auth unless privileged is required.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Sanic request object
|
||||||
|
privileged: If True, requires admin privileges (always enforced even in public mode)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Unauthorized: If authentication is required
|
||||||
|
Forbidden: If access is denied
|
||||||
|
"""
|
||||||
|
# Public mode: skip auth unless privileged access is required
|
||||||
|
if config.config.public and not privileged:
|
||||||
|
return
|
||||||
|
|
||||||
|
sso = _get_sso()
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
perm = "cista:admin" if privileged else "cista:login"
|
||||||
|
await sso.validate_sso_request(request, perm=perm)
|
||||||
|
return
|
||||||
|
|
||||||
|
user = getattr(request.ctx, "user", None)
|
||||||
if privileged:
|
if privileged:
|
||||||
if request.ctx.user:
|
if user and user.privileged:
|
||||||
if request.ctx.user.privileged:
|
|
||||||
return
|
return
|
||||||
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
|
raise Forbidden(
|
||||||
elif config.config.public or request.ctx.user:
|
"Access Forbidden: Only for privileged users",
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
if user:
|
||||||
return
|
return
|
||||||
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
|
raise Unauthorized(
|
||||||
|
f"Login required for {request.path}",
|
||||||
|
"cookie",
|
||||||
|
context={"auth": {"iframe": "/auth/restricted"}},
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("auth")
|
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
|
||||||
|
bp = Blueprint("auth", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/login")
|
@bp.get("/restricted")
|
||||||
async def login_page(request):
|
async def login_page(request):
|
||||||
doc = Document("Cista Login")
|
"""Login page that works both standalone and in paskia iframe."""
|
||||||
with doc.div(id="login"):
|
s = session.get(request)
|
||||||
with doc.form(method="POST", autocomplete="on"):
|
|
||||||
doc.h1("Login")
|
# Check if already logged in
|
||||||
|
if s:
|
||||||
|
# Already authenticated - signal success if in iframe
|
||||||
|
return html(_login_success_page(s["username"]))
|
||||||
|
|
||||||
|
doc = Document("Cista - Login")
|
||||||
|
# Add paskia-compatible styling and scripts
|
||||||
|
doc.style(_LOGIN_PAGE_CSS)
|
||||||
|
with doc.div(class_="login-card"):
|
||||||
|
doc.h1("Authentication Required")
|
||||||
|
with doc.div(class_="content"):
|
||||||
|
with doc.form(method="POST", id="loginForm", autocomplete="on"):
|
||||||
|
doc.label("Username:", for_="username")
|
||||||
doc.input(
|
doc.input(
|
||||||
|
type="text",
|
||||||
|
id="username",
|
||||||
name="username",
|
name="username",
|
||||||
placeholder="Username",
|
autocomplete="username webauthn",
|
||||||
autocomplete="username",
|
|
||||||
required=True,
|
required=True,
|
||||||
).br
|
)
|
||||||
|
doc.label("Password:", for_="password")
|
||||||
doc.input(
|
doc.input(
|
||||||
type="password",
|
type="password",
|
||||||
|
id="password",
|
||||||
name="password",
|
name="password",
|
||||||
placeholder="Password",
|
autocomplete="current-password webauthn",
|
||||||
autocomplete="current-password",
|
|
||||||
required=True,
|
required=True,
|
||||||
).br
|
|
||||||
doc.input(type="submit", value="Login")
|
|
||||||
s = session.get(request)
|
|
||||||
if s:
|
|
||||||
name = s["username"]
|
|
||||||
with doc.form(method="POST", action="/logout"):
|
|
||||||
doc.input(type="submit", value=f"Logout {name}")
|
|
||||||
flash = request.cookies.message
|
|
||||||
if flash:
|
|
||||||
doc.dialog(
|
|
||||||
flash,
|
|
||||||
id="flash",
|
|
||||||
open=True,
|
|
||||||
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
|
|
||||||
)
|
)
|
||||||
|
with doc.div(class_="button-row"):
|
||||||
|
doc.button("Log in", type="submit", id="submitBtn")
|
||||||
|
doc.p("", class_="error", id="error")
|
||||||
|
|
||||||
|
# JavaScript for AJAX login and postMessage communication
|
||||||
|
doc.script_(_LOGIN_PAGE_JS)
|
||||||
|
|
||||||
res = html(doc)
|
res = html(doc)
|
||||||
if flash:
|
|
||||||
res.cookies.delete_cookie("flash")
|
|
||||||
if s is False:
|
if s is False:
|
||||||
session.delete(res)
|
session.delete(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def _login_success_page(username: str) -> str:
|
||||||
|
"""Minimal page that signals auth-success to parent iframe."""
|
||||||
|
return str(
|
||||||
|
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/login")
|
@bp.post("/login")
|
||||||
async def login_post(request):
|
async def login_post(request):
|
||||||
try:
|
try:
|
||||||
@@ -149,7 +361,7 @@ async def login_post(request):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/api/logout")
|
||||||
async def logout_post(request):
|
async def logout_post(request):
|
||||||
s = request.ctx.session
|
s = request.ctx.session
|
||||||
msg = "Logged out" if s else "Not logged in"
|
msg = "Logged out" if s else "Not logged in"
|
||||||
@@ -196,7 +408,7 @@ async def change_password(request):
|
|||||||
|
|
||||||
@bp.get("/users")
|
@bp.get("/users")
|
||||||
async def list_users(request):
|
async def list_users(request):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
users = []
|
users = []
|
||||||
for name, user in config.config.users.items():
|
for name, user in config.config.users.items():
|
||||||
users.append(
|
users.append(
|
||||||
@@ -211,7 +423,7 @@ async def list_users(request):
|
|||||||
|
|
||||||
@bp.post("/users")
|
@bp.post("/users")
|
||||||
async def create_user(request):
|
async def create_user(request):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
try:
|
try:
|
||||||
if request.headers.content_type == "application/json":
|
if request.headers.content_type == "application/json":
|
||||||
username = request.json["username"]
|
username = request.json["username"]
|
||||||
@@ -240,7 +452,7 @@ async def create_user(request):
|
|||||||
|
|
||||||
@bp.put("/users/<username>")
|
@bp.put("/users/<username>")
|
||||||
async def update_user(request, username):
|
async def update_user(request, username):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
try:
|
try:
|
||||||
if request.headers.content_type == "application/json":
|
if request.headers.content_type == "application/json":
|
||||||
changes = request.json
|
changes = request.json
|
||||||
@@ -273,7 +485,7 @@ async def update_user(request, username):
|
|||||||
|
|
||||||
@bp.delete("/users/<username>")
|
@bp.delete("/users/<username>")
|
||||||
async def delete_user(request, username):
|
async def delete_user(request, username):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
if username not in config.config.users:
|
if username not in config.config.users:
|
||||||
raise BadRequest("User does not exist")
|
raise BadRequest("User does not exist")
|
||||||
try:
|
try:
|
||||||
@@ -281,14 +493,3 @@ async def delete_user(request, username):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BadRequest(str(e)) from e
|
raise BadRequest(str(e)) from e
|
||||||
return json({"message": f"User {username} deleted"})
|
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"})
|
|
||||||
|
|||||||
+9
-1
@@ -152,7 +152,15 @@ def modifies_config(
|
|||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
init_confdir()
|
init_confdir()
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
raw = conffile.read_bytes()
|
||||||
|
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
|
||||||
|
# Migrate from old authentication field if present
|
||||||
|
raw_dict = msgspec.toml.decode(raw)
|
||||||
|
if "authentication" in raw_dict and "public" not in raw_dict:
|
||||||
|
# Old config with authentication mode: migrate to public bool
|
||||||
|
new_public = raw_dict["authentication"] == "none"
|
||||||
|
config = msgspec.structs.replace(config, public=new_public)
|
||||||
|
update_config({}) # Save the migrated config
|
||||||
|
|
||||||
|
|
||||||
@modifies_config
|
@modifies_config
|
||||||
|
|||||||
+10
-3
@@ -17,13 +17,20 @@ 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
|
||||||
|
|
||||||
from cista import config
|
from cista import auth, config
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
pillow_heif.register_heif_opener()
|
pillow_heif.register_heif_opener()
|
||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.on_request
|
||||||
|
async def verify_preview(request):
|
||||||
|
"""Verify access to preview routes."""
|
||||||
|
await auth.verify(request)
|
||||||
|
|
||||||
|
|
||||||
# Map EXIF Orientation value to a corresponding PIL transpose
|
# Map EXIF Orientation value to a corresponding PIL transpose
|
||||||
EXIF_ORI = {
|
EXIF_ORI = {
|
||||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||||
@@ -82,9 +89,9 @@ def dispatch(path, quality, maxsize, maxzoom):
|
|||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Cannot generate preview for {path.name}: {e}")
|
logger.warning(f"Cannot generate preview for {path}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error generating preview for {path.name}: {e}")
|
logger.exception(f"Error generating preview for {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
|
|||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
"""SSO (paskia) authentication proxy and validation module.
|
||||||
|
|
||||||
|
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
|
||||||
|
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
|
||||||
|
- All /auth/* requests are proxied to the paskia backend
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
|
||||||
|
Must include scheme (http/https), no trailing slash
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from sanic import Blueprint
|
||||||
|
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
# Auth backend URL for SSO validation (from env, no trailing slash)
|
||||||
|
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
# Validate and set PASKIA_BACKEND_URL
|
||||||
|
if _raw_url:
|
||||||
|
if not re.match(r"^https?://[^\s/]+$", _raw_url):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
|
||||||
|
"must be http(s)://host[:port] with no path or trailing slash"
|
||||||
|
)
|
||||||
|
PASKIA_BACKEND_URL = _raw_url
|
||||||
|
else:
|
||||||
|
PASKIA_BACKEND_URL = ""
|
||||||
|
|
||||||
|
|
||||||
|
def paskia_enabled() -> bool:
|
||||||
|
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
|
||||||
|
return bool(PASKIA_BACKEND_URL)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared httpx client for SSO requests (reused for connection pooling)
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_client() -> httpx.AsyncClient:
|
||||||
|
"""Get or create the shared httpx client."""
|
||||||
|
global _client
|
||||||
|
if _client is None or _client.is_closed:
|
||||||
|
_client = httpx.AsyncClient(timeout=1.0)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_client():
|
||||||
|
"""Close the shared httpx client."""
|
||||||
|
global _client
|
||||||
|
if _client is not None and not _client.is_closed:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
|
||||||
|
"""Validate an SSO request against the auth backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Sanic request object
|
||||||
|
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict if valid, None if validation fails with auth required response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Forbidden: If access is denied (403)
|
||||||
|
Unauthorized: If authentication is required (401)
|
||||||
|
"""
|
||||||
|
if not paskia_enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = await get_client()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if "host" in request.headers:
|
||||||
|
headers["host"] = request.headers["host"]
|
||||||
|
if "cookie" in request.headers:
|
||||||
|
headers["cookie"] = request.headers["cookie"]
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
headers["authorization"] = request.headers["authorization"]
|
||||||
|
headers["accept"] = "application/json"
|
||||||
|
headers["x-forwarded-for"] = request.client_ip
|
||||||
|
headers["x-forwarded-host"] = request.host
|
||||||
|
headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
|
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
request.ctx.sso_user = data
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
request.ctx.sso_user = {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
except Exception:
|
||||||
|
error_data = {"detail": response.text or "Authentication error"}
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
if "auth" in error_data and "iframe" in error_data["auth"]:
|
||||||
|
error_data["auth"]["iframe"] += "&theme=light"
|
||||||
|
raise Unauthorized(
|
||||||
|
error_data.get("detail", "Authentication required"),
|
||||||
|
"cookie",
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
elif response.status_code == 403:
|
||||||
|
raise Forbidden(
|
||||||
|
error_data.get("detail", "Access denied"),
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail = error_data.get("detail", "")
|
||||||
|
logger.warning(
|
||||||
|
f"SSO validation {url} returned {response.status_code}: {detail}"
|
||||||
|
)
|
||||||
|
raise Forbidden(
|
||||||
|
detail or "Authentication error",
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"SSO validation {url} network error: {e}")
|
||||||
|
raise SanicException(
|
||||||
|
"Authentication service unavailable",
|
||||||
|
status_code=502,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_auth_request(request):
|
||||||
|
"""Proxy a request to the auth backend.
|
||||||
|
|
||||||
|
All requests under /auth/ are proxied when paskia mode is enabled.
|
||||||
|
"""
|
||||||
|
client = await get_client()
|
||||||
|
|
||||||
|
path = request.path
|
||||||
|
query_string = request.query_string
|
||||||
|
url = f"{PASKIA_BACKEND_URL}{path}"
|
||||||
|
if query_string:
|
||||||
|
url = f"{url}?{query_string}"
|
||||||
|
|
||||||
|
skip_headers = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
"proxy-authorization",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"forwarded",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in request.headers.items()
|
||||||
|
if key.lower() not in skip_headers
|
||||||
|
]
|
||||||
|
headers.append(("x-forwarded-for", request.client_ip))
|
||||||
|
headers.append(("x-forwarded-host", request.host))
|
||||||
|
headers.append(("x-forwarded-proto", request.scheme))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with client.stream(
|
||||||
|
method=request.method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
content=request.body if request.body else None,
|
||||||
|
) as response:
|
||||||
|
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
|
||||||
|
|
||||||
|
resp_hop_by_hop = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp_headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in response.headers.multi_items()
|
||||||
|
if key.lower() not in resp_hop_by_hop
|
||||||
|
]
|
||||||
|
|
||||||
|
from sanic import raw as raw_response
|
||||||
|
|
||||||
|
return raw_response(
|
||||||
|
raw_content,
|
||||||
|
status=response.status_code,
|
||||||
|
headers=resp_headers,
|
||||||
|
content_type=response.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Auth proxy request failed: {e}")
|
||||||
|
from sanic import json
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{"detail": "Authentication service unavailable", "error": str(e)},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_auth_websocket(request, ws):
|
||||||
|
"""Proxy a WebSocket connection to the auth backend."""
|
||||||
|
path = request.path
|
||||||
|
query_string = request.query_string
|
||||||
|
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
|
||||||
|
"https://", "wss://"
|
||||||
|
)
|
||||||
|
url = f"{ws_backend}{path}"
|
||||||
|
if query_string:
|
||||||
|
url = f"{url}?{query_string}"
|
||||||
|
|
||||||
|
additional_headers = {}
|
||||||
|
if "cookie" in request.headers:
|
||||||
|
additional_headers["cookie"] = request.headers["cookie"]
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
additional_headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "origin" in request.headers:
|
||||||
|
additional_headers["origin"] = request.headers["origin"]
|
||||||
|
if "user-agent" in request.headers:
|
||||||
|
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||||
|
additional_headers["x-forwarded-for"] = request.ip
|
||||||
|
additional_headers["x-forwarded-host"] = request.host
|
||||||
|
additional_headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
url, additional_headers=additional_headers
|
||||||
|
) as backend_ws:
|
||||||
|
|
||||||
|
async def forward_to_backend():
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
await backend_ws.send(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
try:
|
||||||
|
async for message in backend_ws:
|
||||||
|
await ws.send(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
forward_to_backend(),
|
||||||
|
forward_to_client(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_websocket_request(request) -> bool:
|
||||||
|
"""Check if the request is a WebSocket upgrade request."""
|
||||||
|
connection = request.headers.get("connection", "").lower()
|
||||||
|
upgrade = request.headers.get("upgrade", "").lower()
|
||||||
|
connection_tokens = [t.strip() for t in connection.split(",")]
|
||||||
|
return "upgrade" in connection_tokens and upgrade == "websocket"
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_websocket_upgrade(request):
|
||||||
|
"""Handle WebSocket upgrade and proxy the connection."""
|
||||||
|
protocol = request.transport.get_protocol()
|
||||||
|
ws = await protocol.websocket_handshake(request, subprotocols=None)
|
||||||
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
|
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||||
|
bp = Blueprint("sso", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route(
|
||||||
|
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||||
|
)
|
||||||
|
async def auth_proxy(request, path=""):
|
||||||
|
"""Proxy all auth requests to the auth backend."""
|
||||||
|
if _is_websocket_request(request):
|
||||||
|
await _handle_websocket_upgrade(request)
|
||||||
|
from sanic import empty
|
||||||
|
|
||||||
|
return empty()
|
||||||
|
return await proxy_auth_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def auth_proxy_root(request):
|
||||||
|
"""Proxy root auth requests to the auth backend."""
|
||||||
|
if _is_websocket_request(request):
|
||||||
|
await _handle_websocket_upgrade(request)
|
||||||
|
from sanic import empty
|
||||||
|
|
||||||
|
return empty()
|
||||||
|
return await proxy_auth_request(request)
|
||||||
@@ -33,8 +33,11 @@ async def handle_sanic_exception(request, e):
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
# Non-browsers get JSON errors
|
# Non-browsers get JSON errors
|
||||||
if "text/html" not in request.headers.accept:
|
if "text/html" not in request.headers.accept:
|
||||||
|
# Include auth context if present (for SSO auth required responses)
|
||||||
|
# Auth must be at top level for paskia library to detect it
|
||||||
|
response_data = {"code": code, "message": message, "detail": message, **context}
|
||||||
return jres(
|
return jres(
|
||||||
ErrorMsg({"code": code, "message": message, **context}),
|
response_data,
|
||||||
status=code,
|
status=code,
|
||||||
)
|
)
|
||||||
# Redirections flash the error message via cookies
|
# Redirections flash the error message via cookies
|
||||||
@@ -52,7 +55,7 @@ def websocket_wrapper(handler):
|
|||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request, ws, *args, **kwargs):
|
async def wrapper(request, ws, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||||
|
|||||||
+31
-30
@@ -16,39 +16,40 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||||
"@vueuse/core": "^10.4.1",
|
"@vueuse/core": "^14.1.0",
|
||||||
"esbuild": "^0.19.5",
|
"esbuild": "^0.27.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.23",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.23",
|
||||||
"pinia": "^2.1.6",
|
"paskia": "^0.1.2",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia": "^3.0.4",
|
||||||
"unplugin-vue-components": "^0.25.2",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"vite-svg-loader": "^4.0.0",
|
"unplugin-vue-components": "^31.0.0",
|
||||||
"vue": "^3.3.4",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue-router": "^4.2.4"
|
"vue": "^3.5.27",
|
||||||
|
"vue-router": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.3.3",
|
"@rushstack/eslint-patch": "^1.15.0",
|
||||||
"@tsconfig/node18": "^18.2.2",
|
"@tsconfig/node18": "^18.2.6",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/lodash-es": "^4.17.10",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^18.17.17",
|
"@types/node": "^25.1.0",
|
||||||
"@vitejs/plugin-vue": "^4.3.4",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/test-utils": "^2.4.1",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"eslint": "^8.52.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-vue": "^9.18.1",
|
"eslint-plugin-vue": "^10.7.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^27.4.0",
|
||||||
"npm-run-all2": "^6.0.6",
|
"npm-run-all2": "^8.0.4",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.2.0",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^4.4.9",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^0.34.4",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^1.8.11"
|
"vue-tsc": "^3.2.4"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"semi": false,
|
"semi": false,
|
||||||
|
|||||||
+35
-3
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<div v-if="store.toast" class="toast-message" @click="store.clearToast()">
|
||||||
|
{{ store.toast }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
|
<AccessDeniedModal />
|
||||||
<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" />
|
||||||
@@ -30,6 +36,7 @@ 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'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
@@ -39,10 +46,10 @@ interface Path {
|
|||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const path: ComputedRef<Path> = computed(() => {
|
const path: ComputedRef<Path> = computed(() => {
|
||||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||||
const pathList = p[0].split('/').filter(value => value !== '')
|
const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
const query = p.slice(1).join('//')
|
||||||
return {
|
return {
|
||||||
path: p[0],
|
path: p[0] ?? '',
|
||||||
pathList,
|
pathList,
|
||||||
query
|
query
|
||||||
}
|
}
|
||||||
@@ -96,6 +103,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
|
store.clearToast()
|
||||||
headerMain.value!.closeSearch(event)
|
headerMain.value!.closeSearch(event)
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
}
|
}
|
||||||
@@ -158,3 +166,27 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Toast notifications - fixed at top center of viewport */
|
||||||
|
.toast-message {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 90vw;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.toast-message.status {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// SVG icon index - all icons bundled together
|
||||||
|
import AddFile from './add-file.svg'
|
||||||
|
import AddFolder from './add-folder.svg'
|
||||||
|
import Arrow from './arrow.svg'
|
||||||
|
import ArrowsH from './arrows-h.svg'
|
||||||
|
import ArrowsV from './arrows-v.svg'
|
||||||
|
import Check from './check.svg'
|
||||||
|
import Code from './code.svg'
|
||||||
|
import Cog from './cog.svg'
|
||||||
|
import Copy from './copy.svg'
|
||||||
|
import CreateFile from './create-file.svg'
|
||||||
|
import CreateFolder from './create-folder.svg'
|
||||||
|
import Cross from './cross.svg'
|
||||||
|
import Disk from './disk.svg'
|
||||||
|
import Download from './download.svg'
|
||||||
|
import Exclamation from './exclamation.svg'
|
||||||
|
import Eye from './eye.svg'
|
||||||
|
import Find from './find.svg'
|
||||||
|
import Fullscreen from './fullscreen.svg'
|
||||||
|
import Github from './github.svg'
|
||||||
|
import Home from './home.svg'
|
||||||
|
import Info from './info.svg'
|
||||||
|
import Link from './link.svg'
|
||||||
|
import Logo from './logo.svg'
|
||||||
|
import Loop from './loop.svg'
|
||||||
|
import Menu from './menu.svg'
|
||||||
|
import Next from './next.svg'
|
||||||
|
import Open from './open.svg'
|
||||||
|
import Paste from './paste.svg'
|
||||||
|
import Pause from './pause.svg'
|
||||||
|
import Pencil from './pencil.svg'
|
||||||
|
import Play from './play.svg'
|
||||||
|
import Plus from './plus.svg'
|
||||||
|
import Previous from './previous.svg'
|
||||||
|
import Reload from './reload.svg'
|
||||||
|
import Rename from './rename.svg'
|
||||||
|
import Scissors from './scissors.svg'
|
||||||
|
import Shuffle from './shuffle.svg'
|
||||||
|
import Signin from './signin.svg'
|
||||||
|
import Signout from './signout.svg'
|
||||||
|
import Skip from './skip.svg'
|
||||||
|
import Spinner from './spinner.svg'
|
||||||
|
import Stop from './stop.svg'
|
||||||
|
import Trash from './trash.svg'
|
||||||
|
import Triangle from './triangle.svg'
|
||||||
|
import Unfullscreen from './unfullscreen.svg'
|
||||||
|
import UpArrow from './up-arrow.svg'
|
||||||
|
import UploadCloud from './upload-cloud.svg'
|
||||||
|
import UserCog from './user-cog.svg'
|
||||||
|
import User from './user.svg'
|
||||||
|
import VolumeHigh from './volume-high.svg'
|
||||||
|
import VolumeLow from './volume-low.svg'
|
||||||
|
import VolumeMedium from './volume-medium.svg'
|
||||||
|
import VolumeMute from './volume-mute.svg'
|
||||||
|
import WindowCross from './window-cross.svg'
|
||||||
|
import Window from './window.svg'
|
||||||
|
import Wordwrap from './wordwrap.svg'
|
||||||
|
import Zoomin from './zoomin.svg'
|
||||||
|
import Zoomout from './zoomout.svg'
|
||||||
|
|
||||||
|
// Named exports for direct imports
|
||||||
|
export {
|
||||||
|
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
|
||||||
|
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
|
||||||
|
Disk, Download, Exclamation, Eye, Find, Fullscreen,
|
||||||
|
Github, Home, Info, Link, Logo, Loop, Menu,
|
||||||
|
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
|
||||||
|
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
|
||||||
|
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
|
||||||
|
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
|
||||||
|
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
|
||||||
|
Zoomin, Zoomout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon lookup by kebab-case name (for SvgButton compatibility)
|
||||||
|
export const icons = {
|
||||||
|
'add-file': AddFile,
|
||||||
|
'add-folder': AddFolder,
|
||||||
|
'arrow': Arrow,
|
||||||
|
'arrows-h': ArrowsH,
|
||||||
|
'arrows-v': ArrowsV,
|
||||||
|
'check': Check,
|
||||||
|
'code': Code,
|
||||||
|
'cog': Cog,
|
||||||
|
'copy': Copy,
|
||||||
|
'create-file': CreateFile,
|
||||||
|
'create-folder': CreateFolder,
|
||||||
|
'cross': Cross,
|
||||||
|
'disk': Disk,
|
||||||
|
'download': Download,
|
||||||
|
'exclamation': Exclamation,
|
||||||
|
'eye': Eye,
|
||||||
|
'find': Find,
|
||||||
|
'fullscreen': Fullscreen,
|
||||||
|
'github': Github,
|
||||||
|
'home': Home,
|
||||||
|
'info': Info,
|
||||||
|
'link': Link,
|
||||||
|
'logo': Logo,
|
||||||
|
'loop': Loop,
|
||||||
|
'menu': Menu,
|
||||||
|
'next': Next,
|
||||||
|
'open': Open,
|
||||||
|
'paste': Paste,
|
||||||
|
'pause': Pause,
|
||||||
|
'pencil': Pencil,
|
||||||
|
'play': Play,
|
||||||
|
'plus': Plus,
|
||||||
|
'previous': Previous,
|
||||||
|
'reload': Reload,
|
||||||
|
'rename': Rename,
|
||||||
|
'scissors': Scissors,
|
||||||
|
'shuffle': Shuffle,
|
||||||
|
'signin': Signin,
|
||||||
|
'signout': Signout,
|
||||||
|
'skip': Skip,
|
||||||
|
'spinner': Spinner,
|
||||||
|
'stop': Stop,
|
||||||
|
'trash': Trash,
|
||||||
|
'triangle': Triangle,
|
||||||
|
'unfullscreen': Unfullscreen,
|
||||||
|
'up-arrow': UpArrow,
|
||||||
|
'upload-cloud': UploadCloud,
|
||||||
|
'user-cog': UserCog,
|
||||||
|
'user': User,
|
||||||
|
'volume-high': VolumeHigh,
|
||||||
|
'volume-low': VolumeLow,
|
||||||
|
'volume-medium': VolumeMedium,
|
||||||
|
'volume-mute': VolumeMute,
|
||||||
|
'window-cross': WindowCross,
|
||||||
|
'window': Window,
|
||||||
|
'wordwrap': Wordwrap,
|
||||||
|
'zoomin': Zoomin,
|
||||||
|
'zoomout': Zoomout,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IconName = keyof typeof icons
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
||||||
|
<div class="modal-dialog" id="accessdenied">
|
||||||
|
<div class="modal-content access-denied">
|
||||||
|
<p class="icon">⛔</p>
|
||||||
|
<p class="message">Access Denied</p>
|
||||||
|
<button @click="reload" class="button">Reload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { holdGlobalBackdrop } from 'paskia'
|
||||||
|
import { watchEffect } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep backdrop active when this dialog shows
|
||||||
|
watchEffect(() => {
|
||||||
|
if (store.dialog === 'accessdenied') {
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.access-denied {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem !important;
|
||||||
|
}
|
||||||
|
.access-denied .icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
.access-denied .message {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -31,11 +31,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import home from '@/assets/svg/home.svg'
|
import { Home } from '@/assets/svg'
|
||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
|
const home = Home
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const links = [] as Array<HTMLElement>
|
const links = [] as Array<HTMLElement>
|
||||||
@@ -54,7 +55,7 @@ const isCurrent = (index: number) => index == props.path.length ? 'location' : u
|
|||||||
const focusCurrent = () => {
|
const focusCurrent = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const index = props.path.length
|
const index = props.path.length
|
||||||
if (index < links.length) links[index].focus()
|
if (index < links.length) links[index]!.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ const navigate = (index: number) => {
|
|||||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||||
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
||||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0])
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Clicking on current link clears the rest of the path and adds new history
|
// Clicking on current link clears the rest of the path and adds new history
|
||||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
// Global activation state - shared across all instances
|
// Global activation state - shared across all instances
|
||||||
let globalActive = false
|
let globalActive = false
|
||||||
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// Track if we've seen real mouse movement (not touch-simulated)
|
||||||
|
let hasRealMouse = false
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -23,44 +25,88 @@ const props = defineProps<{
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const mouseX = ref(0)
|
const mouseX = ref(0)
|
||||||
const mouseY = ref(0)
|
const mouseY = ref(0)
|
||||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null
|
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastMoveX = 0
|
||||||
|
let lastMoveY = 0
|
||||||
|
|
||||||
|
// Movement threshold (pixels) - cursor must settle within this radius
|
||||||
|
const SETTLE_THRESHOLD = 8
|
||||||
|
|
||||||
const tooltipStyle = computed(() => ({
|
const tooltipStyle = computed(() => ({
|
||||||
left: `${mouseX.value + 12}px`,
|
left: `${mouseX.value}px`,
|
||||||
top: `${mouseY.value + 12}px`,
|
top: `${mouseY.value}px`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const startHover = (e: MouseEvent) => {
|
// Check if the device likely has a real mouse (fine pointer)
|
||||||
mouseX.value = e.clientX
|
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches
|
||||||
mouseY.value = e.clientY
|
|
||||||
// Clear any pending deactivation
|
const showTooltip = () => {
|
||||||
|
visible.value = true
|
||||||
|
globalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTooltip = () => {
|
||||||
|
if (settleTimer) clearTimeout(settleTimer)
|
||||||
if (globalDeactivateTimer) {
|
if (globalDeactivateTimer) {
|
||||||
clearTimeout(globalDeactivateTimer)
|
clearTimeout(globalDeactivateTimer)
|
||||||
globalDeactivateTimer = null
|
globalDeactivateTimer = null
|
||||||
}
|
}
|
||||||
const delay = globalActive ? 0 : (props.delay ?? 800)
|
const delay = globalActive ? 0 : (props.delay ?? 900)
|
||||||
hoverTimer = setTimeout(() => {
|
settleTimer = setTimeout(showTooltip, delay)
|
||||||
visible.value = true
|
}
|
||||||
globalActive = true
|
|
||||||
}, delay)
|
const startHover = (e: MouseEvent) => {
|
||||||
|
// Ignore touch events (no fine pointer and no confirmed real mouse)
|
||||||
|
if (!hasFinePointer() && !hasRealMouse) return
|
||||||
|
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
lastMoveX = e.clientX
|
||||||
|
lastMoveY = e.clientY
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePosition = (e: MouseEvent) => {
|
const updatePosition = (e: MouseEvent) => {
|
||||||
|
// Detect real mouse via movement (touch events don't generate continuous mousemove)
|
||||||
|
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true
|
||||||
|
if (!hasFinePointer() && !hasRealMouse) return
|
||||||
|
|
||||||
mouseX.value = e.clientX
|
mouseX.value = e.clientX
|
||||||
mouseY.value = e.clientY
|
mouseY.value = e.clientY
|
||||||
|
|
||||||
|
// If tooltip is already visible, just update position
|
||||||
|
if (visible.value) return
|
||||||
|
|
||||||
|
const dx = e.clientX - lastMoveX
|
||||||
|
const dy = e.clientY - lastMoveY
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
// If cursor moved beyond threshold, reset settle timer
|
||||||
|
if (distance > SETTLE_THRESHOLD) {
|
||||||
|
lastMoveX = e.clientX
|
||||||
|
lastMoveY = e.clientY
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer)
|
||||||
|
settleTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule tooltip when cursor settles
|
||||||
|
if (!settleTimer) {
|
||||||
|
scheduleTooltip()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const endHover = () => {
|
const endHover = () => {
|
||||||
if (hoverTimer) {
|
if (settleTimer) {
|
||||||
clearTimeout(hoverTimer)
|
clearTimeout(settleTimer)
|
||||||
hoverTimer = null
|
settleTimer = null
|
||||||
}
|
}
|
||||||
visible.value = false
|
visible.value = false
|
||||||
// Deactivate global state after a short delay if no new tooltip started
|
// Deactivate global state after a short delay if no new tooltip started
|
||||||
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
|
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
|
||||||
globalDeactivateTimer = setTimeout(() => {
|
globalDeactivateTimer = setTimeout(() => {
|
||||||
globalActive = false
|
globalActive = false
|
||||||
}, 500)
|
}, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
|
import { zipName } from '@/utils/fileutil'
|
||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -96,7 +98,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
const writable = await fileHandle.createWritable()
|
const writable = await fileHandle.createWritable()
|
||||||
const url = `/files/${rel}`
|
const url = `/files/${rel}`
|
||||||
console.log('Fetching', url)
|
console.log('Fetching', url)
|
||||||
const res = await fetch(url)
|
const res = await apiFetch(url)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
store.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}`)
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||||
@@ -105,7 +107,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
++store.dprogress.fileidx
|
++store.dprogress.fileidx
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
await writable.truncate(0)
|
await writable.truncate(0)
|
||||||
store.error = "Direct download."
|
|
||||||
store.dprogress.tlast = Date.now()
|
store.dprogress.tlast = Date.now()
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read()
|
const { value, done } = await reader.read()
|
||||||
@@ -135,7 +136,7 @@ const download = async () => {
|
|||||||
console.log('Download', sel)
|
console.log('Download', sel)
|
||||||
if (sel.keys.length === 0) {
|
if (sel.keys.length === 0) {
|
||||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||||
store.error = 'No existing files selected'
|
store.showToast('No existing files selected')
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -143,8 +144,8 @@ const download = async () => {
|
|||||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
store.error = "Single file via browser downloads"
|
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
|
||||||
return linkdl(`/files/${files[0][1]}`)
|
return linkdl(`/files/${files[0]![1]}`)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Use FileSystem API if multiple files and the browser supports it
|
||||||
if ('showDirectoryPicker' in window) {
|
if ('showDirectoryPicker' in window) {
|
||||||
@@ -163,9 +164,10 @@ const download = async () => {
|
|||||||
}
|
}
|
||||||
// Otherwise, zip and download
|
// Otherwise, zip and download
|
||||||
console.log("Falling back to zip download")
|
console.log("Falling back to zip download")
|
||||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
const items = sel.keys.map(k => sel.docs[k]!)
|
||||||
|
const name = zipName(items)
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||||
store.error = "Downloading as ZIP via browser downloads"
|
store.showToast(`Downloading ${name}.zip`)
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
||||||
<component :is="cog" class="cog"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="!store.connected">No Connection</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
||||||
<p v-else-if="store.query">No matches!</p>
|
<p v-else-if="store.query">No matches!</p>
|
||||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
@@ -11,9 +12,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import cog from '@/assets/svg/cog.svg'
|
import { Cog } from '@/assets/svg'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: string[],
|
path: string[],
|
||||||
@@ -34,4 +36,7 @@ svg.cog {
|
|||||||
filter: drop-shadow(0 0 1rem black);
|
filter: drop-shadow(0 0 1rem black);
|
||||||
fill: #888;
|
fill: #888;
|
||||||
}
|
}
|
||||||
|
svg.cog.stopped {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ defineExpose({
|
|||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
if (p === N) continue
|
if (p === N) continue
|
||||||
const key = docs[p].key
|
const key = docs[p]!.key
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
else store.selected.add(key)
|
else store.selected.add(key)
|
||||||
}
|
}
|
||||||
@@ -255,8 +255,8 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
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: () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Doc } from '@/repositories/Document'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]
|
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||||
return +unit ? "bytes" : unit
|
return +unit ? "bytes" : unit
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ defineExpose({
|
|||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
if (p === N) continue
|
if (p === N) continue
|
||||||
const key = docs[p].key
|
const key = docs[p]!.key
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
else store.selected.add(key)
|
else store.selected.add(key)
|
||||||
}
|
}
|
||||||
@@ -209,8 +209,8 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="headermain buttons">
|
<nav class="headermain buttons">
|
||||||
<template v-if="store.error">
|
|
||||||
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
|
||||||
<div class="smallgap"></div>
|
|
||||||
</template>
|
|
||||||
<UploadButton :path="props.path" />
|
<UploadButton :path="props.path" />
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
@@ -30,14 +26,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import { ref, nextTick, watchEffect } from 'vue'
|
import { ref, nextTick, watchEffect } from 'vue'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
|
import { showAuthIframe } from 'paskia'
|
||||||
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
const ssoStore = useSsoAuthStore()
|
||||||
const showSearchInput = ref<boolean>(false)
|
const showSearchInput = ref<boolean>(false)
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const searchButton = ref<HTMLButtonElement | null>()
|
const searchButton = ref<HTMLButtonElement | null>()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
@@ -50,14 +51,22 @@ const closeSearch = (ev: Event) => {
|
|||||||
breadcrumb.focus()
|
breadcrumb.focus()
|
||||||
updateSearch(ev)
|
updateSearch(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSearch = (ev: Event) => {
|
const updateSearch = (ev: Event) => {
|
||||||
const q = (ev.target as HTMLInputElement).value
|
const q = (ev.target as HTMLInputElement).value
|
||||||
let p = props.path.join('/')
|
let p = props.path.join('/')
|
||||||
p = p ? `/${p}` : ''
|
p = p ? `/${p}` : ''
|
||||||
const url = q ? `${p}//${q}` : (p || '/')
|
const url = q ? `${p}//${q}` : (p || '/')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
|
|
||||||
|
// Start search immediately via store (worker handles it async)
|
||||||
|
store.search(q, props.path.join('/'))
|
||||||
|
|
||||||
|
// Update route in next frame to keep typing responsive
|
||||||
|
requestAnimationFrame(() => {
|
||||||
if (!props.query && q) router.push(u)
|
if (!props.query && q) router.push(u)
|
||||||
else router.replace(u)
|
else router.replace(u)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const toggleSearchInput = (ev: Event) => {
|
const toggleSearchInput = (ev: Event) => {
|
||||||
showSearchInput.value = !showSearchInput.value
|
showSearchInput.value = !showSearchInput.value
|
||||||
@@ -73,14 +82,36 @@ watchEffect(() => {
|
|||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
|
|
||||||
if (store.user.privileged) {
|
// For external auth, show user name as link to /auth/
|
||||||
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
|
items.push({
|
||||||
|
label: '👤 ' + (store.user.username || 'User Account'),
|
||||||
|
onClick: () => { window.location.href = '/auth/' }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show password change for non-SSO users
|
||||||
|
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
|
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', onClick: () => store.logout() })
|
||||||
} else {
|
} else if (store.server.public) {
|
||||||
items.push({ label: 'Login', onClick: () => store.loginDialog() })
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
|
items.push({ label: '🔐 Login', onClick: async () => {
|
||||||
|
try {
|
||||||
|
await showAuthIframe('/auth/restricted#theme=light')
|
||||||
|
resumeWatching()
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Login cancelled')
|
||||||
|
}
|
||||||
|
}})
|
||||||
}
|
}
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const op = (op: string, dst?: string) => {
|
|||||||
const msg = {
|
const msg = {
|
||||||
op,
|
op,
|
||||||
sel: sel.keys.map(key => {
|
sel: sel.keys.map(key => {
|
||||||
const doc = sel.docs[key]
|
const doc = sel.docs[key]!
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModalDialog name="login" title="Authentication required">
|
|
||||||
<form @submit.prevent="login">
|
|
||||||
<div class="login-container">
|
|
||||||
<label for="username">Username:</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
autocomplete="username"
|
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
|
||||||
v-model="loginForm.username"
|
|
||||||
/>
|
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
|
||||||
v-model="loginForm.password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 class="error-text">
|
|
||||||
{{ loginForm.error || '\u00A0' }}
|
|
||||||
</h3>
|
|
||||||
<div class="dialog-buttons">
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<input id="submit" type="submit" value="Login" class="button-login" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ModalDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { loginUser } from '@/repositories/User'
|
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
|
||||||
const store = useMainStore()
|
|
||||||
|
|
||||||
const loginForm = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
error: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
try {
|
|
||||||
loginForm.error = ''
|
|
||||||
confirmLoading.value = true
|
|
||||||
const msg = await loginUser(loginForm.username, loginForm.password)
|
|
||||||
store.login(msg.data.username, !!msg.data.privileged)
|
|
||||||
} catch (error) {
|
|
||||||
const httpError = error as ISimpleError
|
|
||||||
loginForm.error = httpError.message || '🛑 Unknown error'
|
|
||||||
} finally {
|
|
||||||
confirmLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-container {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.button-login {
|
|
||||||
color: #fff;
|
|
||||||
background: var(--soft-color);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 0;
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: .5rem 2rem;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: all var(--transition-time) linear;
|
|
||||||
}
|
|
||||||
.button-login:hover, .button-login:focus {
|
|
||||||
background: var(--accent-color);
|
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
|
||||||
.error-text {
|
|
||||||
color: var(--red-color);
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -16,7 +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'
|
import { Play as PlayIcon } from '@/assets/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)
|
||||||
@@ -46,7 +46,7 @@ const next = () => {
|
|||||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||||
for (const i in medias) {
|
for (const i in medias) {
|
||||||
if (medias[i] === (fscurrent || media.value)) {
|
if (medias[i] === (fscurrent || media.value)) {
|
||||||
el = medias[+i + 1] || medias[0]
|
el = medias[+i + 1] ?? medias[0] ?? null
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
|
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
|
||||||
|
<div class="modal-dialog" :id="props.name" ref="dialog">
|
||||||
<h1 v-if="props.title">{{ props.title }}</h1>
|
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||||
<div>
|
<div class="modal-content">
|
||||||
<slot>
|
<slot>
|
||||||
Dialog with no content
|
Dialog with no content
|
||||||
<button @click=close>OK</button>
|
<button @click="close">OK</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watchEffect, nextTick } from 'vue'
|
import { ref, watchEffect, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
|
||||||
const dialog = ref<HTMLDialogElement | null>(null)
|
const overlay = ref<HTMLDivElement | null>(null)
|
||||||
|
const dialog = ref<HTMLDivElement | null>(null)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
dialog.value!.close()
|
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
|
releaseGlobalBackdrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -29,62 +33,247 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
store.dialog = props.name
|
store.dialog = props.name
|
||||||
setTimeout(() => {
|
holdGlobalBackdrop()
|
||||||
dialog.value!.showModal()
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const input = dialog.value!.querySelector('input')
|
overlay.value?.focus()
|
||||||
|
const input = dialog.value?.querySelector('input')
|
||||||
if (input) input.focus()
|
if (input) input.focus()
|
||||||
})
|
})
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
defineExpose({ show, close })
|
defineExpose({ show, close })
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (dialog.value) show()
|
if (overlay.value) {
|
||||||
|
overlay.value.focus()
|
||||||
|
const input = dialog.value?.querySelector('input')
|
||||||
|
if (input) input.focus()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Style for the background */
|
/* ===========================================
|
||||||
dialog::backdrop {
|
MODAL DIALOG GLOBAL STYLES
|
||||||
content: '';
|
Shared styling for all modal dialogs.
|
||||||
display: block;
|
Login page (auth.py) has matching CSS.
|
||||||
|
=========================================== */
|
||||||
|
|
||||||
|
/* Overlay - covers entire viewport */
|
||||||
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
z-index: 1100;
|
||||||
width: 100%;
|
display: flex;
|
||||||
height: 100%;
|
align-items: center;
|
||||||
background: #0008;
|
justify-content: center;
|
||||||
backdrop-filter: blur(0.4em);
|
/* No backdrop - paskia handles that */
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the dialog by default */
|
/* Dialog container */
|
||||||
dialog[open] {
|
.modal-dialog {
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
color: black;
|
color: #000;
|
||||||
display: block;
|
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.2rem;
|
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0.2rem 0.2rem 1rem #000;
|
box-shadow: 0 0 1rem #0008;
|
||||||
padding: 1rem;
|
padding: 0;
|
||||||
position: fixed;
|
max-width: 90vw;
|
||||||
top: 0;
|
max-height: 90vh;
|
||||||
left: 0;
|
overflow: auto;
|
||||||
z-index: 1001;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
font: inherit;
|
/* Dialog title bar */
|
||||||
}
|
.modal-dialog > h1 {
|
||||||
dialog[open] > h1 {
|
background: #146;
|
||||||
background: var(--soft-color);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin: -1rem -1rem 0 -1rem;
|
font-weight: normal;
|
||||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
margin: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog[open] > div {
|
/* Dialog content area */
|
||||||
padding: 1em 0;
|
.modal-dialog > .modal-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headings inside dialog */
|
||||||
|
.modal-dialog h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.modal-dialog h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.modal-dialog a {
|
||||||
|
color: #146;
|
||||||
|
}
|
||||||
|
.modal-dialog a:hover {
|
||||||
|
color: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.modal-dialog input[type="text"],
|
||||||
|
.modal-dialog input[type="password"],
|
||||||
|
.modal-dialog select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog input[type="text"]:focus,
|
||||||
|
.modal-dialog input[type="password"]:focus,
|
||||||
|
.modal-dialog select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.modal-dialog label {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.modal-dialog button,
|
||||||
|
.modal-dialog input[type="submit"],
|
||||||
|
.modal-dialog input[type="reset"],
|
||||||
|
.modal-dialog .button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog button:hover,
|
||||||
|
.modal-dialog input[type="submit"]:hover,
|
||||||
|
.modal-dialog input[type="reset"]:hover,
|
||||||
|
.modal-dialog .button:hover {
|
||||||
|
background: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog button:disabled,
|
||||||
|
.modal-dialog input[type="submit"]:disabled,
|
||||||
|
.modal-dialog input[type="reset"]:disabled,
|
||||||
|
.modal-dialog .button:disabled {
|
||||||
|
background: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small button variant */
|
||||||
|
.modal-dialog .button.small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger button variant */
|
||||||
|
.modal-dialog .button.danger {
|
||||||
|
background: #c00;
|
||||||
|
}
|
||||||
|
.modal-dialog .button.danger:hover:not(:disabled) {
|
||||||
|
background: #f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form row layout (label + input side by side) */
|
||||||
|
.modal-dialog .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form grid for multiple label+input pairs */
|
||||||
|
.modal-dialog .form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog button row (footer) */
|
||||||
|
.modal-dialog .dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error text */
|
||||||
|
.modal-dialog .error-text {
|
||||||
|
color: #c00;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.modal-dialog .success-message {
|
||||||
|
background: #f80;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data tables inside dialogs */
|
||||||
|
.modal-dialog table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog th,
|
||||||
|
.modal-dialog td {
|
||||||
|
border: 1px solid #888;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog th {
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog td {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox alignment in tables */
|
||||||
|
.modal-dialog td input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraph text */
|
||||||
|
.modal-dialog p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.modal-dialog .loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<form>
|
<form>
|
||||||
<template v-if="store.user.isLoggedIn">
|
<template v-if="store.user.isLoggedIn">
|
||||||
<h3>Update your authentication</h3>
|
<h3>Update your authentication</h3>
|
||||||
<div class="login-container">
|
<div class="form-grid">
|
||||||
<label for="username">New password:</label>
|
<label for="passwordChange">New password:</label>
|
||||||
<input
|
<input
|
||||||
ref="passwordChange"
|
ref="passwordChange"
|
||||||
id="passwordChange"
|
id="passwordChange"
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="error-text">
|
|
||||||
{{ form.error || '\u00A0' }}
|
|
||||||
</h3>
|
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
@@ -54,28 +51,26 @@ import { useMainStore } from '@/stores/main'
|
|||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
const confirmLoading = ref<boolean>(false)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const passwordChange = ref()
|
const passwordChange = ref()
|
||||||
const password = ref()
|
const password = ref()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
passwordChange: '',
|
passwordChange: '',
|
||||||
password: '',
|
password: ''
|
||||||
error: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
form.passwordChange = ''
|
form.passwordChange = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.error = ''
|
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
}
|
}
|
||||||
const submit = async (ev: Event) => {
|
const submit = async (ev: Event) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
try {
|
try {
|
||||||
form.error = ''
|
|
||||||
if (form.passwordChange) {
|
if (form.passwordChange) {
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
form.error = '⚠️ Current password is required'
|
store.showToast('⚠️ Current password is required')
|
||||||
password.value!.focus()
|
password.value!.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,7 +79,7 @@ const submit = async (ev: Event) => {
|
|||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const httpError = error as ISimpleError
|
const httpError = error as ISimpleError
|
||||||
form.error = httpError.message || '🛑 Unknown error'
|
store.showToast(httpError.message || '🛑 Unknown error')
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false
|
confirmLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -92,36 +87,5 @@ const submit = async (ev: Event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-container {
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.button-login {
|
|
||||||
color: #fff;
|
|
||||||
background: var(--soft-color);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 0;
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: .5rem 2rem;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: all var(--transition-time) linear;
|
|
||||||
}
|
|
||||||
.button-login:hover, .button-login:focus {
|
|
||||||
background: var(--accent-color);
|
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
|
||||||
.error-text {
|
|
||||||
color: var(--red-color);
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="action-button">
|
<button class="action-button">
|
||||||
<component :is="icon" />
|
<component :is="icons[name]" />
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { icons, type IconName } from '@/assets/svg'
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
name: string
|
name: IconName
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const uploadFiles = (infiles: File[]) => {
|
|||||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||||
if (dotfiles.length) {
|
if (dotfiles.length) {
|
||||||
store.error = "Won't upload dotfiles"
|
store.showToast("Won't upload dotfiles")
|
||||||
console.log("Dotfiles omitted", dotfiles)
|
console.log("Dotfiles omitted", dotfiles)
|
||||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
|||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
const ws = await WSCreate()
|
const ws = await WSCreate()
|
||||||
while (upqueue.length) {
|
while (upqueue.length) {
|
||||||
const f = upqueue[0]
|
const f = upqueue[0]!
|
||||||
const start = f.cloudPos
|
const start = f.cloudPos
|
||||||
const end = Math.min(f.file.size, start + (1<<20))
|
const end = Math.min(f.file.size, start + (1<<20))
|
||||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
const control = { name: f.cloudName, size: f.file.size, start, end }
|
||||||
|
|||||||
@@ -4,21 +4,28 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<h3>Server Settings</h3>
|
<h3>Server Settings</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
<label for="publicAccess">
|
||||||
<input
|
<input
|
||||||
id="publicServer"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
id="publicAccess"
|
||||||
v-model="serverSettings.public"
|
v-model="serverSettings.public"
|
||||||
@change="updateServerSettings"
|
@change="updateServerSettings"
|
||||||
/>
|
/>
|
||||||
<label for="publicServer">Publicly accessible without any user account.</label>
|
Public access (anyone can read and write)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="store.server.paskia">
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<h3>Users</h3>
|
<h3>Users</h3>
|
||||||
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||||
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
||||||
{{ success }}
|
{{ success }}
|
||||||
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="user-table">
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
@@ -45,7 +52,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
|
</template>
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<button @click="close" class="button">Close</button>
|
<button @click="close" class="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +75,6 @@ interface User {
|
|||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const error = ref('')
|
|
||||||
const success = ref('')
|
const success = ref('')
|
||||||
const copyButtonText = ref('📋')
|
const copyButtonText = ref('📋')
|
||||||
const serverSettings = reactive({
|
const serverSettings = reactive({
|
||||||
@@ -77,7 +83,6 @@ const serverSettings = reactive({
|
|||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ const loadUsers = async () => {
|
|||||||
users.value = data.users
|
users.value = data.users
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to load users'
|
store.showToast(httpError.message || 'Failed to load users')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -98,7 +103,6 @@ const addUser = async () => {
|
|||||||
const username = window.prompt('Enter username for new user:')
|
const username = window.prompt('Enter username for new user:')
|
||||||
if (!username || !username.trim()) return
|
if (!username || !username.trim()) return
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await createUser(username.trim(), undefined, false)
|
const result = await createUser(username.trim(), undefined, false)
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
@@ -107,19 +111,18 @@ const addUser = async () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to add user'
|
store.showToast(httpError.message || 'Failed to add user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAdmin = async (user: User, event: Event) => {
|
const toggleAdmin = async (user: User, event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
await updateUser(user.username, { privileged: target.checked })
|
await updateUser(user.username, { privileged: target.checked })
|
||||||
user.privileged = target.checked
|
user.privileged = target.checked
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to update user'
|
store.showToast(httpError.message || 'Failed to update user')
|
||||||
target.checked = user.privileged // revert
|
target.checked = user.privileged // revert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +133,6 @@ const renameUser = async (user: User) => {
|
|||||||
// For rename, we need to create new user and delete old, or have a rename endpoint
|
// For rename, we need to create new user and delete old, or have a rename endpoint
|
||||||
// Since no rename endpoint, perhaps delete and create
|
// Since no rename endpoint, perhaps delete and create
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await createUser(newName.trim(), undefined, user.privileged)
|
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||||||
await deleteUser(user.username)
|
await deleteUser(user.username)
|
||||||
@@ -140,14 +142,13 @@ const renameUser = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to rename user'
|
store.showToast(httpError.message || 'Failed to rename user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPassword = async (user: User) => {
|
const resetPassword = async (user: User) => {
|
||||||
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await updateUser(user.username, { password: "" })
|
const result = await updateUser(user.username, { password: "" })
|
||||||
if (result.password) {
|
if (result.password) {
|
||||||
@@ -155,26 +156,25 @@ const resetPassword = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to reset password'
|
store.showToast(httpError.message || 'Failed to reset password')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteUserAction = async (username: string) => {
|
const deleteUserAction = async (username: string) => {
|
||||||
if (!confirm(`Delete user ${username}?`)) return
|
if (!confirm(`Delete user ${username}?`)) return
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
await deleteUser(username)
|
await deleteUser(username)
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to delete user'
|
store.showToast(httpError.message || 'Failed to delete user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||||
if (passwordMatch) {
|
if (passwordMatch) {
|
||||||
await navigator.clipboard.writeText(passwordMatch[1])
|
await navigator.clipboard.writeText(passwordMatch[1]!)
|
||||||
if (isButtonClick) {
|
if (isButtonClick) {
|
||||||
// Show "Copied!" indication on button
|
// Show "Copied!" indication on button
|
||||||
copyButtonText.value = '✅ Copied!'
|
copyButtonText.value = '✅ Copied!'
|
||||||
@@ -195,7 +195,6 @@ const copySuccess = async (isButtonClick: boolean = false) => {
|
|||||||
|
|
||||||
const updateServerSettings = async () => {
|
const updateServerSettings = async () => {
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
await updatePublic(serverSettings.public)
|
await updatePublic(serverSettings.public)
|
||||||
// Update store
|
// Update store
|
||||||
@@ -203,63 +202,27 @@ const updateServerSettings = async () => {
|
|||||||
success.value = 'Server settings updated'
|
success.value = 'Server settings updated'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to update settings'
|
store.showToast(httpError.message || 'Failed to update settings')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
serverSettings.public = store.server.public
|
serverSettings.public = store.server.public || false
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load users when dialog opens (only in built-in auth mode)
|
||||||
|
watch(() => store.dialog, (newVal) => {
|
||||||
|
if (newVal === 'usermgmt' && !store.server.paskia) {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => store.server.public, (newVal) => {
|
watch(() => store.server.public, (newVal) => {
|
||||||
serverSettings.public = newVal
|
serverSettings.public = newVal || false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-table {
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
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>
|
</style>
|
||||||
@@ -1,71 +1,71 @@
|
|||||||
|
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
// Type for API error responses
|
||||||
|
interface ApiError {
|
||||||
|
error: {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasError(msg: unknown): msg is ApiError {
|
||||||
|
return typeof msg === 'object' && msg !== null && 'error' in msg
|
||||||
|
}
|
||||||
|
|
||||||
class ClientClass {
|
class ClientClass {
|
||||||
async get(url: string): Promise<any> {
|
async get(url: string): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, { method: 'GET' })
|
||||||
} catch (e) {
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
|
||||||
}
|
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
'content-type': 'application/json'
|
|
||||||
},
|
|
||||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, {
|
||||||
} catch (e) {
|
method: 'POST',
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
body: data
|
||||||
}
|
})
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
return msg
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async put(url: string, data?: Record<string, any>): Promise<any> {
|
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 {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, {
|
||||||
} catch (e) {
|
method: 'PUT',
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
body: data
|
||||||
}
|
})
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
return msg
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async delete(url: string): Promise<any> {
|
async delete(url: string): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, { method: 'DELETE' })
|
||||||
} catch (e) {
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
|
||||||
}
|
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { apiFetch }
|
||||||
export default Client
|
export default Client
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
import { formatSize, formatUnixDate } from "@/utils"
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
@@ -12,20 +12,23 @@ export type DocProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
private _name: string = ""
|
|
||||||
public loc: string = ""
|
public loc: string = ""
|
||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public haystack: string = ""
|
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
|
/** @internal Use the name getter/setter instead */
|
||||||
|
public _name: string = ""
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) }
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
|
const { name, ...rest } = props
|
||||||
|
Object.assign(this, rest)
|
||||||
|
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||||
|
}
|
||||||
get name() { return this._name }
|
get name() { return this._name }
|
||||||
set name(name: string) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
this._name = name
|
this._name = name
|
||||||
this.haystack = haystackFormat(name)
|
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string { return formatSize(this.size) }
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
get modified(): string { return formatUnixDate(this.mtime) }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Client from '@/repositories/Client'
|
import Client from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
export const url_login = '/login'
|
export const url_login = '/auth/login'
|
||||||
export const url_logout = '/logout'
|
export const url_logout = '/auth/api/logout'
|
||||||
export const url_password = '/password-change'
|
export const url_password = '/auth/password-change'
|
||||||
|
|
||||||
export async function loginUser(username: string, password: string) {
|
export async function loginUser(username: string, password: string) {
|
||||||
const user = await Client.post(url_login, {
|
const user = await Client.post(url_login, {
|
||||||
@@ -25,7 +25,7 @@ export async function changePassword(username: string, passwordChange: string, p
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const url_users = '/users'
|
export const url_users = '/auth/users'
|
||||||
|
|
||||||
export async function listUsers() {
|
export async function listUsers() {
|
||||||
const data = await Client.get(url_users)
|
const data = await Client.get(url_users)
|
||||||
@@ -51,7 +51,7 @@ export async function deleteUser(username: string) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePublic(publicFlag: boolean) {
|
export async function updatePublic(isPublic: boolean) {
|
||||||
const data = await Client.put('/config/public', { public: publicFlag })
|
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMainStore } from "@/stores/main"
|
import { useMainStore } from "@/stores/main"
|
||||||
|
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
||||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||||
|
|
||||||
export const controlUrl = '/api/control'
|
export const controlUrl = '/api/control'
|
||||||
@@ -8,6 +9,13 @@ export const watchUrl = '/api/watch'
|
|||||||
let tree = [] as FileEntry[]
|
let tree = [] as FileEntry[]
|
||||||
let reconnDelay = 500
|
let reconnDelay = 500
|
||||||
let wsWatch = null as WebSocket | null
|
let wsWatch = null as WebSocket | null
|
||||||
|
// Track when we're awaiting authentication to prevent reconnection loops
|
||||||
|
let awaitingAuth = false
|
||||||
|
|
||||||
|
// Clear the local tree cache (called on logout/auth failure)
|
||||||
|
export const clearTree = () => {
|
||||||
|
tree = []
|
||||||
|
}
|
||||||
|
|
||||||
export const loadSession = () => {
|
export const loadSession = () => {
|
||||||
const s = localStorage['cista-files']
|
const s = localStorage['cista-files']
|
||||||
@@ -34,6 +42,44 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
|
|||||||
return webSocket
|
return webSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
|
||||||
|
async function handleWsAuthError(msg: any) {
|
||||||
|
const iframe = msg.error?.auth?.iframe
|
||||||
|
if (iframe) {
|
||||||
|
// Clear sensitive data immediately on auth failure
|
||||||
|
const store = useMainStore()
|
||||||
|
store.clearSensitiveData()
|
||||||
|
clearTree()
|
||||||
|
// Stop reconnection attempts while showing auth dialog
|
||||||
|
awaitingAuth = true
|
||||||
|
store.authInProgress = true
|
||||||
|
store.error = '' // Clear any connection message
|
||||||
|
if (watchTimeout !== null) {
|
||||||
|
clearTimeout(watchTimeout)
|
||||||
|
watchTimeout = null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await showAuthIframe(iframe)
|
||||||
|
// Auth succeeded - reconnect
|
||||||
|
awaitingAuth = false
|
||||||
|
store.authInProgress = false
|
||||||
|
watchConnect()
|
||||||
|
} catch (e) {
|
||||||
|
awaitingAuth = false
|
||||||
|
store.authInProgress = false
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
console.log('User cancelled authentication')
|
||||||
|
// Show access denied dialog
|
||||||
|
store.dialog = 'accessdenied'
|
||||||
|
} else {
|
||||||
|
console.error('Auth iframe error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export const watchConnect = () => {
|
export const watchConnect = () => {
|
||||||
if (watchTimeout !== null) {
|
if (watchTimeout !== null) {
|
||||||
clearTimeout(watchTimeout)
|
clearTimeout(watchTimeout)
|
||||||
@@ -51,9 +97,9 @@ export const watchConnect = () => {
|
|||||||
if (store.connected) return
|
if (store.connected) return
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||||
store.user.isLoggedIn = false
|
// Show paskia auth iframe (works for both password and paskia modes)
|
||||||
store.dialog = 'login'
|
handleWsAuthError(msg)
|
||||||
} else {
|
} else {
|
||||||
store.error = msg.error.message
|
store.error = msg.error.message
|
||||||
}
|
}
|
||||||
@@ -67,7 +113,6 @@ export const watchConnect = () => {
|
|||||||
store.error = ''
|
store.error = ''
|
||||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||||
else if (store.isUserLogged) store.logout()
|
else if (store.isUserLogged) store.logout()
|
||||||
if (!msg.server.public && !msg.user) store.dialog = 'login'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -78,21 +123,31 @@ export const watchDisconnect = () => {
|
|||||||
wsWatch = null
|
wsWatch = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset auth state and reconnect - call after successful authentication
|
||||||
|
export const resumeWatching = () => {
|
||||||
|
awaitingAuth = false
|
||||||
|
if (watchTimeout !== null) {
|
||||||
|
clearTimeout(watchTimeout)
|
||||||
|
watchTimeout = null
|
||||||
|
}
|
||||||
|
watchConnect()
|
||||||
|
}
|
||||||
|
|
||||||
let watchTimeout: any = null
|
let watchTimeout: any = null
|
||||||
|
|
||||||
const watchReconnect = (event: MessageEvent) => {
|
const watchReconnect = (event: MessageEvent) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Don't reconnect if we're awaiting authentication or auth iframe is showing
|
||||||
|
if (awaitingAuth || isAuthIframeOpen()) {
|
||||||
|
console.log('Skipping reconnect - awaiting authentication')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (store.connected) {
|
if (store.connected) {
|
||||||
console.warn("Disconnected from server", event)
|
console.warn("Disconnected from server", event)
|
||||||
store.connected = false
|
store.connected = false
|
||||||
store.error = 'Reconnecting...'
|
store.error = 'Reconnecting...'
|
||||||
}
|
}
|
||||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||||
// Don't hammer the server while on login dialog
|
|
||||||
if (store.dialog === 'login') {
|
|
||||||
watchTimeout = setTimeout(watchReconnect, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reconnDelay = Math.min(5000, reconnDelay + 500)
|
reconnDelay = Math.min(5000, reconnDelay + 500)
|
||||||
// The server closes the websocket after errors, so we need to reopen it
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||||
@@ -152,9 +207,9 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
|
|
||||||
function handleError(msg: errorEvent) {
|
function handleError(msg: errorEvent) {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||||
store.user.isLoggedIn = false
|
// Show paskia auth iframe (works for both password and paskia modes)
|
||||||
store.dialog = 'login'
|
handleWsAuthError(msg as any)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-18
@@ -2,23 +2,54 @@ import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { defineStore, type StateTree } from 'pinia'
|
import { defineStore, type StateTree } from 'pinia'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { logoutUser } from '@/repositories/User'
|
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||||
import { watchConnect } from '@/repositories/WS'
|
|
||||||
import { shallowRef } from 'vue'
|
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
|
|
||||||
export const useMainStore = defineStore({
|
// Singleton search worker instance
|
||||||
id: 'main',
|
let searchWorker: Worker | null = null
|
||||||
|
let searchId = 0
|
||||||
|
let searchStore: ReturnType<typeof useMainStore> | null = null
|
||||||
|
|
||||||
|
function getSearchWorker(): Worker {
|
||||||
|
if (!searchWorker) {
|
||||||
|
searchWorker = new SearchWorker()
|
||||||
|
// Set up message handler once
|
||||||
|
searchWorker.onmessage = (e) => {
|
||||||
|
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||||
|
|
||||||
|
// Convert plain data back to Doc instances (constructor is now lightweight)
|
||||||
|
const docs = []
|
||||||
|
for (const d of e.data.docs) {
|
||||||
|
docs.push(new Doc(d))
|
||||||
|
}
|
||||||
|
searchStore.searchResults = docs
|
||||||
|
|
||||||
|
if (e.data.done) {
|
||||||
|
searchStore.searchLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return searchWorker
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMainStore = defineStore('main', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: shallowRef<Doc[]>([]),
|
document: [] as Doc[],
|
||||||
selected: new Set<FUID>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
|
searchResults: [] as Doc[],
|
||||||
|
searchLoading: false,
|
||||||
|
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||||
|
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||||
|
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||||
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
@@ -33,7 +64,7 @@ export const useMainStore = defineStore({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
paths: ['prefs', 'cursor', 'selected'],
|
pick: ['prefs', 'cursor', 'selected'],
|
||||||
serializer: {
|
serializer: {
|
||||||
deserialize: (data: string): StateTree => {
|
deserialize: (data: string): StateTree => {
|
||||||
const ret = JSON.parse(data)
|
const ret = JSON.parse(data)
|
||||||
@@ -63,23 +94,90 @@ export const useMainStore = defineStore({
|
|||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
this.document = docs
|
this.document = docs
|
||||||
|
// Sync documents to search worker
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
|
/** Show a temporary toast message that auto-dismisses */
|
||||||
|
showToast(message: string, duration = 3000) {
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout)
|
||||||
|
this.toastTimeout = null
|
||||||
|
}
|
||||||
|
this.toast = message
|
||||||
|
this.toastTimeout = setTimeout(() => {
|
||||||
|
this.toast = ''
|
||||||
|
this.toastTimeout = null
|
||||||
|
}, duration)
|
||||||
|
},
|
||||||
|
/** Clear the current toast immediately */
|
||||||
|
clearToast() {
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout)
|
||||||
|
this.toastTimeout = null
|
||||||
|
}
|
||||||
|
this.toast = ''
|
||||||
|
},
|
||||||
|
syncSearchWorker() {
|
||||||
|
const worker = getSearchWorker()
|
||||||
|
// Send plain data to worker (no class instances)
|
||||||
|
const docData = this.document.map(doc => ({
|
||||||
|
loc: doc.loc,
|
||||||
|
name: doc.name,
|
||||||
|
key: doc.key,
|
||||||
|
size: doc.size,
|
||||||
|
mtime: doc.mtime,
|
||||||
|
dir: doc.dir,
|
||||||
|
}))
|
||||||
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
|
},
|
||||||
|
search(query: string, loc: string) {
|
||||||
|
const worker = getSearchWorker()
|
||||||
|
const id = ++searchId
|
||||||
|
searchStore = this // Store reference for worker callback
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
this.searchResults = []
|
||||||
|
this.searchLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchLoading = true
|
||||||
|
worker.postMessage({ type: 'search', query, loc, id })
|
||||||
},
|
},
|
||||||
login(username: string, privileged: boolean) {
|
login(username: string, privileged: boolean) {
|
||||||
this.user.username = username
|
this.user.username = username
|
||||||
this.user.privileged = privileged
|
this.user.privileged = privileged
|
||||||
this.user.isLoggedIn = true
|
this.user.isLoggedIn = true
|
||||||
this.dialog = ''
|
this.dialog = ''
|
||||||
if (!this.connected) watchConnect()
|
if (!this.connected) resumeWatching()
|
||||||
},
|
},
|
||||||
loginDialog() {
|
clearSensitiveData() {
|
||||||
this.dialog = 'login'
|
// Clear all sensitive state on logout or auth failure
|
||||||
|
localStorage.removeItem('cista-files')
|
||||||
|
this.document = []
|
||||||
|
this.selected.clear()
|
||||||
|
this.user.username = ''
|
||||||
|
this.user.privileged = false
|
||||||
|
this.user.isLoggedIn = false
|
||||||
|
this.connected = false
|
||||||
|
this.dialog = ''
|
||||||
|
this.cursor = ''
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log("Logout")
|
console.log("Logout")
|
||||||
await logoutUser()
|
try {
|
||||||
this.$reset()
|
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||||
localStorage.clear()
|
if (!res.ok) {
|
||||||
history.go() // Reload page
|
const data = await res.json().catch(() => ({}))
|
||||||
|
this.error = data.message || data.detail || 'Logout failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Logout failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.clearSensitiveData()
|
||||||
|
resumeWatching()
|
||||||
},
|
},
|
||||||
toggleSort(name: SortOrder) {
|
toggleSort(name: SortOrder) {
|
||||||
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
||||||
@@ -129,7 +227,7 @@ export const useMainStore = defineStore({
|
|||||||
ret.recursive.push([rel, full, doc])
|
ret.recursive.push([rel, full, doc])
|
||||||
}
|
}
|
||||||
for (const key of ret.keys) {
|
for (const key of ret.keys) {
|
||||||
const base = ret.docs[key]
|
const base = ret.docs[key]!
|
||||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
add(base.name, basepath, base)
|
add(base.name, basepath, base)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useMainStore } from './main'
|
||||||
|
import { clearTree } from '@/repositories/WS'
|
||||||
|
|
||||||
|
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||||
|
const isExternalAuth = computed(() => {
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
return mainStore.server?.paskia === true
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearDataOnUnauth() {
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
mainStore.clearSensitiveData()
|
||||||
|
clearTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isExternalAuth, clearDataOnUnauth }
|
||||||
|
})
|
||||||
@@ -41,7 +41,7 @@ export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
|
|||||||
// Find the "best" item in each folder (first after sorting = best according to criteria)
|
// Find the "best" item in each folder (first after sorting = best according to criteria)
|
||||||
const folderBest = new Map<string, Doc>()
|
const folderBest = new Map<string, Doc>()
|
||||||
for (const [folder, docs] of byFolder) {
|
for (const [folder, docs] of byFolder) {
|
||||||
folderBest.set(folder, docs[0])
|
folderBest.set(folder, docs[0]!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort folders: by path for name sort, by best item for modified/size
|
// Sort folders: by path for name sort, by best item for modified/size
|
||||||
|
|||||||
@@ -6,3 +6,44 @@ export const exists = (path: string[]) => {
|
|||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
export const stripExt = (name: string): string => {
|
||||||
|
// Common compound extensions
|
||||||
|
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
for (const ext of compoundExts) {
|
||||||
|
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
|
||||||
|
}
|
||||||
|
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
|
||||||
|
const lastDot = name.lastIndexOf('.')
|
||||||
|
if (lastDot > 0) {
|
||||||
|
const ext = name.slice(lastDot + 1)
|
||||||
|
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
|
||||||
|
return name.slice(0, lastDot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a sensible zip filename for a selection of items */
|
||||||
|
export const zipName = (items: { name: string; loc: string }[]): string => {
|
||||||
|
const names = items.map(d => d.name)
|
||||||
|
if (names.length === 1) {
|
||||||
|
// Single item - use its name
|
||||||
|
return stripExt(names[0]!)
|
||||||
|
}
|
||||||
|
// Check if all items share the same direct parent folder
|
||||||
|
const locs = items.map(d => d.loc)
|
||||||
|
const sameLoc = locs.every(loc => loc === locs[0])
|
||||||
|
if (sameLoc && locs[0]) {
|
||||||
|
// All items in same folder - use folder name
|
||||||
|
return locs[0].split('/').pop()!
|
||||||
|
}
|
||||||
|
if (names.length <= 3) {
|
||||||
|
// Few items from different folders - join basenames with dot
|
||||||
|
return names.map(stripExt).join('.')
|
||||||
|
}
|
||||||
|
// Many items from different folders - first basename + indicator
|
||||||
|
return `${stripExt(names[0]!)}.etc`
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function getFileType(name: string): string {
|
|||||||
const dotIndex = name.lastIndexOf('.')
|
const dotIndex = name.lastIndexOf('.')
|
||||||
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||||
const ext = name.slice(dotIndex + 1).toLowerCase()
|
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||||
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
|
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prebuilt for fast & consistent sorting
|
// Prebuilt for fast & consistent sorting
|
||||||
|
|||||||
@@ -2,25 +2,25 @@
|
|||||||
<Gallery
|
<Gallery
|
||||||
v-if="store.prefs.gallery"
|
v-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<FileExplorer
|
<FileExplorer
|
||||||
v-else
|
v-else
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`explorer-${Router.currentRoute.value.path}`"
|
:key="`explorer-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
<EmptyFolder :documents=documents :path=props.path />
|
<EmptyFolder :documents=documents :path=props.path />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed, watch } from 'vue'
|
import { watchEffect, ref, computed, watch } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import Router from '@/router/index'
|
import { collator } from '@/utils'
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
|
||||||
@@ -30,41 +30,42 @@ const props = defineProps<{
|
|||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
// Trigger search when query changes (from route, e.g., page load or back button)
|
||||||
|
// Note: Direct typing triggers search immediately via HeaderMain, this is for route-based changes
|
||||||
|
watch(
|
||||||
|
() => [props.query, props.path.join('/')] as const,
|
||||||
|
([query, loc]) => {
|
||||||
|
// Only trigger if results don't match current query (avoid duplicate searches)
|
||||||
|
if (query && store.searchResults.length === 0) {
|
||||||
|
store.search(query, loc)
|
||||||
|
} else if (!query) {
|
||||||
|
store.search('', loc) // Clear search results
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const documents = computed(() => {
|
const documents = computed(() => {
|
||||||
const loc = props.path.join('/')
|
const loc = props.path.join('/')
|
||||||
const query = props.query
|
const query = props.query
|
||||||
// List the current location
|
|
||||||
|
// List the current location (no search)
|
||||||
if (!query) return sorted(
|
if (!query) return sorted(
|
||||||
store.document.filter(doc => doc.loc === loc),
|
store.document.filter(doc => doc.loc === loc),
|
||||||
store.prefs.sortListing,
|
store.prefs.sortListing,
|
||||||
)
|
)
|
||||||
// Find up to 100 newest documents that match the search
|
|
||||||
const needle = needleFormat(query)
|
// Search results from worker
|
||||||
let limit = 100
|
const docs = store.searchResults
|
||||||
let docs = []
|
|
||||||
for (const doc of store.recentDocuments) {
|
|
||||||
if (localeIncludes(doc.haystack, needle)) {
|
|
||||||
docs.push(doc)
|
|
||||||
if (--limit === 0) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const locsub = loc + '/'
|
|
||||||
// Custom sort override in effect? Use grouped sorting to keep folders together
|
// 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 sortedGrouped(docs, order)
|
if (order) return sortedGrouped(docs, order)
|
||||||
// Sort by relevance - current folder, then subfolders, then others
|
|
||||||
docs.sort((a, b) => (
|
// Results are already sorted by relevance in the worker
|
||||||
// @ts-ignore
|
|
||||||
(b.loc === loc) - (a.loc === loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
|
||||||
collator.compare(a.loc, b.loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(a.type === 'file') - (b.type === 'file') ||
|
|
||||||
// @ts-ignore
|
|
||||||
b.name.includes(query) - a.name.includes(query) ||
|
|
||||||
collator.compare(a.name, b.name)
|
|
||||||
))
|
|
||||||
return docs
|
return docs
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,4 +93,15 @@ watch([() => props.path.join('/'), () => store.document.length], ([path, len], [
|
|||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
.search-loading {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Search worker - runs search in background thread
|
||||||
|
// Receives document updates and search queries, returns incremental results
|
||||||
|
|
||||||
|
interface DocData {
|
||||||
|
loc: string
|
||||||
|
name: string
|
||||||
|
key: string
|
||||||
|
size: number
|
||||||
|
mtime: number
|
||||||
|
dir: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerDoc extends DocData {
|
||||||
|
haystack: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchMessage {
|
||||||
|
type: 'search'
|
||||||
|
query: string
|
||||||
|
loc: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateMessage {
|
||||||
|
type: 'update'
|
||||||
|
documents: DocData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingMessage = SearchMessage | UpdateMessage
|
||||||
|
|
||||||
|
interface ResultMessage {
|
||||||
|
type: 'results'
|
||||||
|
docs: DocData[]
|
||||||
|
id: number
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker state
|
||||||
|
let documents: WorkerDoc[] = []
|
||||||
|
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||||
|
let currentSearchId = 0
|
||||||
|
|
||||||
|
// Haystack formatting (same as main thread utils)
|
||||||
|
function haystackFormat(str: string): string {
|
||||||
|
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||||
|
return '^' + based + '$'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needle formatting
|
||||||
|
function needleFormat(query: string) {
|
||||||
|
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||||
|
return { based, words: based.split(/\s+/) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if haystack includes needle
|
||||||
|
function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean {
|
||||||
|
const { based, words } = filter
|
||||||
|
return haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collator for sorting
|
||||||
|
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
||||||
|
|
||||||
|
// Sort by mtime descending
|
||||||
|
function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] {
|
||||||
|
return [...docs].sort((a, b) => b.mtime - a.mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield control to check for new messages
|
||||||
|
function yieldControl(): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform search with incremental results
|
||||||
|
async function performSearch(query: string, loc: string, searchId: number) {
|
||||||
|
const needle = needleFormat(query)
|
||||||
|
const limit = 100
|
||||||
|
const batchSize = 500 // Smaller batches for faster incremental feedback
|
||||||
|
const results: WorkerDoc[] = []
|
||||||
|
let lastResultCount = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) {
|
||||||
|
// Check if search was superseded
|
||||||
|
if (currentSearchId !== searchId) return
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const end = Math.min(i + batchSize, recentDocuments.length)
|
||||||
|
for (let j = i; j < end && results.length < limit; j++) {
|
||||||
|
const doc = recentDocuments[j]!
|
||||||
|
if (localeIncludes(doc.haystack, needle)) {
|
||||||
|
results.push(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post incremental results if we found new matches
|
||||||
|
if (results.length > lastResultCount && currentSearchId === searchId) {
|
||||||
|
lastResultCount = results.length
|
||||||
|
const sortedResults = sortResults(results, query, loc)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: sortedResults.map(stripHaystack),
|
||||||
|
id: searchId,
|
||||||
|
done: false
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield control between batches to allow new search requests to interrupt
|
||||||
|
if (i + batchSize < recentDocuments.length && results.length < limit) {
|
||||||
|
await yieldControl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post final results
|
||||||
|
if (currentSearchId === searchId) {
|
||||||
|
const sortedResults = sortResults(results, query, loc)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: sortedResults.map(stripHaystack),
|
||||||
|
id: searchId,
|
||||||
|
done: true
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort results by relevance
|
||||||
|
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||||
|
const locsub = loc + '/'
|
||||||
|
return [...docs].sort((a, b) => (
|
||||||
|
// Current folder first
|
||||||
|
// @ts-ignore
|
||||||
|
(b.loc === loc) - (a.loc === loc) ||
|
||||||
|
// Then subfolders
|
||||||
|
// @ts-ignore
|
||||||
|
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
||||||
|
// Then by location
|
||||||
|
collator.compare(a.loc, b.loc) ||
|
||||||
|
// Files after folders
|
||||||
|
// @ts-ignore
|
||||||
|
(a.dir === false) - (b.dir === false) ||
|
||||||
|
// Exact name match first
|
||||||
|
// @ts-ignore
|
||||||
|
b.name.includes(query) - a.name.includes(query) ||
|
||||||
|
// Finally by name
|
||||||
|
collator.compare(a.name, b.name)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip haystack before sending back to main thread
|
||||||
|
function stripHaystack(doc: WorkerDoc): DocData {
|
||||||
|
const { haystack, ...rest } = doc
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||||
|
const msg = e.data
|
||||||
|
|
||||||
|
if (msg.type === 'update') {
|
||||||
|
// Update document list with haystacks
|
||||||
|
documents = msg.documents.map(doc => ({
|
||||||
|
...doc,
|
||||||
|
haystack: haystackFormat(doc.name)
|
||||||
|
}))
|
||||||
|
recentDocuments = sortByRecent(documents)
|
||||||
|
} else if (msg.type === 'search') {
|
||||||
|
currentSearchId = msg.id
|
||||||
|
if (msg.query) {
|
||||||
|
await performSearch(msg.query, msg.loc, msg.id)
|
||||||
|
} else {
|
||||||
|
// Empty query - no results needed (main thread handles folder listing)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: [],
|
||||||
|
id: msg.id,
|
||||||
|
done: true
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* FastAPI-Vue Vite Plugin
|
||||||
|
*
|
||||||
|
* Configures Vite for FastAPI backend integration:
|
||||||
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
|
* - Builds to the Python module's frontend-build directory
|
||||||
|
*
|
||||||
|
* Environment variables (with defaults):
|
||||||
|
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||||
|
*/
|
||||||
|
|
||||||
|
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
||||||
|
|
||||||
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
|
// Build proxy configuration for each path
|
||||||
|
const proxy = {}
|
||||||
|
for (const path of paths) {
|
||||||
|
proxy[path] = {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: false,
|
||||||
|
ws: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "fastapi-vite",
|
||||||
|
config: () => ({
|
||||||
|
server: { proxy },
|
||||||
|
build: {
|
||||||
|
outDir: "../cista/frontend-build",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-20
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
@@ -7,15 +8,11 @@ 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'
|
||||||
|
|
||||||
const dev_backend = {
|
|
||||||
target: process.env.CISTA_BACKEND_URL || "http://localhost:8989",
|
|
||||||
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
|
||||||
ws: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
|
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
|
||||||
vue(),
|
vue(),
|
||||||
svgLoader(), // import svg files
|
svgLoader(), // import svg files
|
||||||
Components(), // auto import components
|
Components(), // auto import components
|
||||||
@@ -33,19 +30,16 @@ export default defineConfig({
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": dev_backend,
|
|
||||||
"/files": dev_backend,
|
|
||||||
"/login": dev_backend,
|
|
||||||
"/logout": dev_backend,
|
|
||||||
"/password-change": dev_backend,
|
|
||||||
"/zip": dev_backend,
|
|
||||||
"/preview": dev_backend,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
outDir: "../cista/frontend-build",
|
rollupOptions: {
|
||||||
emptyOutDir: true,
|
output: {
|
||||||
}
|
manualChunks: {
|
||||||
|
// Bundle all SVG icons into a single chunk
|
||||||
|
icons: [
|
||||||
|
'/src/assets/svg/index.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-1
@@ -27,7 +27,11 @@ 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",
|
||||||
"docopt>=0.6.2",
|
"docopt-ng>=0.9.0",
|
||||||
|
"fastapi-vue>=0.5.1",
|
||||||
|
"fastapi[standard]>=0.128.0",
|
||||||
|
"html5tagger>=1.3.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
"inotify>=0.2.12",
|
"inotify>=0.2.12",
|
||||||
"msgspec>=0.19.0",
|
"msgspec>=0.19.0",
|
||||||
"natsort>=8.4.0",
|
"natsort>=8.4.0",
|
||||||
|
|||||||
+67
-170
@@ -2,208 +2,105 @@
|
|||||||
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/devserver.py [-l <listen>]
|
uv run scripts/devserver.py [frontend] [--backend backend]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-l LISTEN Listen address for backend (default: from config, or :8000)
|
frontend Vite frontend endpoint (default: localhost:5173)
|
||||||
|
--backend Cista backend endpoint (default: from config, or :8000)
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
|
||||||
|
|
||||||
import httpx
|
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||||
|
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||||
|
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.serve import parse_listen
|
from cista.serve import parse_listen
|
||||||
|
|
||||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
DEFAULT_BACKEND_PORT = 8000
|
||||||
|
|
||||||
DEFAULT_VITE_PORT = 5173
|
|
||||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
|
||||||
|
|
||||||
BUN_BUG = """\
|
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
||||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
"""Parse backend listen address and build cista dev command.
|
||||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
|
||||||
┃
|
Returns (url, cmd).
|
||||||
┃ Consider using deno or npm instead for development.
|
|
||||||
"""
|
"""
|
||||||
|
config.load_config()
|
||||||
|
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
|
||||||
|
url, opts = parse_listen(listen)
|
||||||
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
|
host = opts.get("host", "localhost") or "localhost"
|
||||||
|
|
||||||
|
cmd = ["cista", "--dev", "-l", listen]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
def resolve_frontend_tools(vite_port: int) -> tuple[list[str], list[str], str]:
|
async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
||||||
"""Resolve frontend install and dev commands.
|
reporoot = Path(__file__).parent.parent
|
||||||
|
front = reporoot / "frontend"
|
||||||
Returns (install_cmd, dev_cmd, tool_name).
|
if not (front / "package.json").exists():
|
||||||
Raises SystemExit if tools are not available.
|
logger.warning("Frontend source not found at %s", front)
|
||||||
"""
|
|
||||||
if not (FRONTEND_PATH / "package.json").exists():
|
|
||||||
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||||
dev_cmd, name = find_dev_tool() # noqa # type: ignore
|
backend_url, sanic_cmd = setup_sanic_backend(backend)
|
||||||
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}"]
|
# Tell vite where to proxy API requests
|
||||||
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||||
|
|
||||||
if name == "bun":
|
async with ProcessGroup() as pg:
|
||||||
stderr.write(BUN_BUG)
|
install_proc = await pg.spawn(*npm_install, cwd=str(front))
|
||||||
|
await asyncio.sleep(0.2) # reduce message overlap
|
||||||
|
await pg.spawn(*sanic_cmd, cwd=str(reporoot))
|
||||||
|
|
||||||
return install_cmd, dev_cmd, name
|
# Wait for both install and backend to be ready
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
tg.create_task(pg.wait(install_proc))
|
||||||
|
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
|
||||||
|
|
||||||
|
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
||||||
async def wait_for_backend(host: str, port: int):
|
await pg.spawn(*vite, cwd=str(front))
|
||||||
"""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():
|
def main():
|
||||||
# Pass all arguments to cista, parse -l to determine backend port
|
parser = argparse.ArgumentParser(
|
||||||
cista_args = sys.argv[1:]
|
description="Run Vite and Cista (Sanic) development servers",
|
||||||
listen_arg = None
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
if "-l" in cista_args:
|
epilog=HELP_EPILOG,
|
||||||
idx = cista_args.index("-l")
|
)
|
||||||
if idx + 1 < len(cista_args):
|
parser.add_argument(
|
||||||
listen_arg = cista_args[idx + 1]
|
"frontend",
|
||||||
|
nargs="?",
|
||||||
# Load config to get the backend port
|
metavar="host:port",
|
||||||
config.load_config()
|
help="Vite frontend endpoint (default: localhost:5173)",
|
||||||
listen = listen_arg or config.config.listen or ":8000"
|
)
|
||||||
_, opts = parse_listen(listen)
|
parser.add_argument(
|
||||||
backend_port = opts.get("port", 8000)
|
"--backend",
|
||||||
|
"-l",
|
||||||
|
metavar="host:port",
|
||||||
|
help="Cista backend endpoint (default: from config, or :8000)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(backend_port, cista_args))
|
asyncio.run(run_devserver(args.frontend, args.backend))
|
||||||
|
|
||||||
|
|
||||||
|
HELP_EPILOG = """
|
||||||
|
scripts/devserver.py # Default ports
|
||||||
|
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||||
|
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||||
|
|
||||||
|
JS_RUNTIME environment variable can be used to select the JS runtime
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,34 +1,15 @@
|
|||||||
"""Hatch build hook for building Vue frontend during package build."""
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
import subprocess
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||||
|
|
||||||
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from buildutil import build
|
||||||
|
|
||||||
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):
|
class CustomBuildHook(BuildHookInterface):
|
||||||
"""Build hook that compiles Vue frontend before packaging."""
|
|
||||||
|
|
||||||
def initialize(self, version, build_data):
|
def initialize(self, version, build_data):
|
||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
stderr.write(">>> Building the frontend\n")
|
build("frontend")
|
||||||
|
|
||||||
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,191 @@
|
|||||||
|
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class _PrefixFormatter(logging.Formatter):
|
||||||
|
"""Formatter that adds prefix based on log level."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.WARNING:
|
||||||
|
return f"┃ ⚠️ {record.getMessage()}"
|
||||||
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(_PrefixFormatter())
|
||||||
|
logger = logging.getLogger("fastapi-vue")
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
"""Check if Node.js version is >= 20.
|
||||||
|
|
||||||
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
version_str = result.stdout.strip()
|
||||||
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
|
match = re.match(r"v(\d+)", version_str)
|
||||||
|
if match:
|
||||||
|
major_version = int(match.group(1))
|
||||||
|
if major_version >= 20:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
raise RuntimeError("Could not determine Node.js version")
|
||||||
|
|
||||||
|
|
||||||
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
|
Raises JSRuntimeError if no suitable runtime is found.
|
||||||
|
"""
|
||||||
|
options = ["npm", "deno", "bun"]
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||||
|
)
|
||||||
|
# Check Node.js version if using npm
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||||
|
)
|
||||||
|
_check_node_version(node_path) # Raises on failure
|
||||||
|
return tool, option
|
||||||
|
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||||
|
|
||||||
|
# Auto-detect
|
||||||
|
for option in options:
|
||||||
|
if tool := shutil.which(option):
|
||||||
|
# Check Node.js version if using npm
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue # Try next runtime
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
# No runtime found - provide helpful error
|
||||||
|
if node_version_error:
|
||||||
|
raise node_version_error
|
||||||
|
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install[name]], [tool, *build[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_dev_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct dev command.
|
||||||
|
|
||||||
|
Returns dev_cmd (without vite-specific args).
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "dev", "--"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
|
||||||
|
if name == "bun":
|
||||||
|
logger.warning(
|
||||||
|
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [tool, *dev_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_install_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct install command.
|
||||||
|
|
||||||
|
Returns install_cmd.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install_args = {
|
||||||
|
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install", "--silent"),
|
||||||
|
"bun": ("install", "--silent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def build(folder: str = "frontend") -> None:
|
||||||
|
"""Build the frontend in the specified folder.
|
||||||
|
|
||||||
|
Raises SystemExit(1) on failure.
|
||||||
|
"""
|
||||||
|
logger.info(">>> Building %s", folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_cmd, build_cmd = find_build_tool()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(e)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||||
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(install_cmd)
|
||||||
|
logger.info("")
|
||||||
|
run(build_cmd)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = 5173
|
||||||
|
DEFAULT_BACKEND_PORT = 5180
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessGroup:
|
||||||
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
|
|
||||||
|
async def spawn(
|
||||||
|
self, *cmd: str, cwd: str | None = None
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
"""Spawn a subprocess and track it."""
|
||||||
|
logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]]))
|
||||||
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
|
self._procs.append(proc)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def wait(self, proc: asyncio.subprocess.Process) -> None:
|
||||||
|
"""Wait for a process to complete, raise SystemExit(1) on failure."""
|
||||||
|
if await proc.wait() != 0:
|
||||||
|
logger.warning("Command failed")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, *_):
|
||||||
|
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||||
|
cleanup_task = asyncio.create_task(self._cleanup())
|
||||||
|
try:
|
||||||
|
await asyncio.shield(cleanup_task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Shield was cancelled but cleanup_task continues - wait for it
|
||||||
|
await cleanup_task
|
||||||
|
|
||||||
|
async def _cleanup(self):
|
||||||
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for any one process to exit
|
||||||
|
await asyncio.wait(
|
||||||
|
[asyncio.create_task(p.wait()) for p in running],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Terminate remaining processes
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Wait for all to finish (with overall timeout)
|
||||||
|
still_running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if still_running:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(*[p.wait() for p in still_running]),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await p.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def ready(url: str, path: str = "") -> None:
|
||||||
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
|
Raises SystemExit(1) if server doesn't start in time.
|
||||||
|
"""
|
||||||
|
max_attempts = 50
|
||||||
|
full_url = f"{url}{path}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
await client.get(full_url, timeout=1.0)
|
||||||
|
logger.info("✓ Backend ready!")
|
||||||
|
return
|
||||||
|
except httpx.RequestError:
|
||||||
|
if attempt == max_attempts - 1:
|
||||||
|
logger.warning("Backend didn't start in time")
|
||||||
|
raise SystemExit(1)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
|
||||||
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
|
Returns (url, install_cmd, dev_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
|
||||||
|
install_cmd = find_install_tool()
|
||||||
|
dev_cmd = find_dev_tool()
|
||||||
|
if host != "localhost":
|
||||||
|
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||||
|
if port != 5173:
|
||||||
|
dev_cmd.append(f"--port={port}")
|
||||||
|
|
||||||
|
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def setup_fastapi(
|
||||||
|
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend endpoint and build fastapi dev command.
|
||||||
|
|
||||||
|
Returns (url, cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"fastapi",
|
||||||
|
"dev",
|
||||||
|
"--entrypoint",
|
||||||
|
module,
|
||||||
|
"--host",
|
||||||
|
host,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""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