Compare commits

...
38 Commits
Author SHA1 Message Date
LeoVasanko 232fd92b22 Finalize Paskia integration and built-in authentication. 2026-01-31 00:47:04 +00:00
LeoVasanko be69164c8f Bundle icons into a single file. 2026-01-30 19:12:27 +00:00
LeoVasanko 4f39875786 TypeScript check and typing corrections. 2026-01-30 19:03:37 +00:00
LeoVasanko 21250a1a2d Upgrade frontend packages, Pinia API changes. 2026-01-30 18:53:25 +00:00
LeoVasanko 849b1a6868 Update scripts with latest fastapi-vue-setup. 2026-01-30 18:40:26 +00:00
LeoVasanko 7be02e951d Consistent dialog styling widgets and using Paskia's shared backdrop. Internal password auth mimics Paskia. API paths changed (/auth goes to internal or paskia depending on config). All API calls and previews get access checks. 2026-01-30 18:28:05 +00:00
LeoVasanko bb38328c24 Improved tooltip display behavior; disabled for touch, less eager to activate on mouse, closer to cursor. 2026-01-22 18:08:50 +00:00
LeoVasanko 146497d731 Fix display of vertical images and videos such they fit in the gallery item. 2026-01-22 02:35:24 +00:00
LeoVasanko 442816a0ae Fix video preview images that were displayed sideways for portrait video. The least bad approach loses HDR but shows in correct orientation. For 0 and 180 degrees we preserve HDR too. 2026-01-22 02:20:39 +00:00
LeoVasanko d32afa6016 Display play button on video previews to indicate it is a video that can be played. 2026-01-22 01:43:50 +00:00
LeoVasanko fa60c962c4 Attempt a better mobile layout, to fix sizing issues with Brave Android. 2026-01-22 00:57:30 +00:00
LeoVasanko e55e11b399 Fix flickering tooltip when hovering modified in FileExplorer. Use the new, improved tooltip also in gallery to show full name, modified and size of an item. 2026-01-22 00:49:50 +00:00
LeoVasanko b6c21152e7 Fix modified tooltip (exact timestamp) flickering on/off because of timestamp updates every second. Made the tooltip follow mouse cursor. 2026-01-22 00:39:26 +00:00
LeoVasanko f354fc5c71 Less aggressive automatic gallery mode switching, only when folder changes. Fixes issues with focus being lost from search. 2026-01-22 00:32:18 +00:00
LeoVasanko 5bda809921 Cleaner folder headers in gallery mode search results. Multi folder results are always grouped by folder (FileExplorer and Gallery), but still otherwise respecting the chosen sort order. Overall this produces a much cleaner layout. 2026-01-22 00:23:23 +00:00
LeoVasanko 2cc92cd786 Deprecation, remove unused import. 2026-01-22 00:16:59 +00:00
LeoVasanko ba6380e71e Add a script to run devserver. Migrate to build and JS utils provided by fastapi-vue. Frontend directory renamed to frontend-build. Update Sanic, deprecations. 2026-01-21 23:32:04 +00:00
LeoVasanko 0d853032bf Cleaner handling when preview generation fails. Using original file as fallback. 2026-01-21 23:29:51 +00:00
LeoVasanko 1cb512e65d Remove overly eager Gallery automode. No longer switches to gallery when merely changing sort column. 2025-10-01 07:23:24 +00:00
LeoVasanko 972aaee9fe Improved reliability of direct to folder downloads. 2025-10-01 06:49:50 +00:00
LeoVasanko 055eaa8a21 Fix admin UI password reset and user deletion functions. 2025-10-01 06:31:20 +00:00
LeoVasanko 05fb81c36d Implement web-based user management / admin setup. (#8)
Implement Admin Settings dialog for user management and toggling the public server flag, not needing CLI for maintenance anymore.
2025-09-30 23:10:33 +00:00
LeoVasanko 6639174e8f Fix Vue TypeScript module declarations for build 2025-10-01 05:32:14 +00:00
LeoVasanko 5e2e71eafb Clean up the remaining uses of print() 2025-08-18 22:49:40 +00:00
LeoVasanko 7e4c5bc911 Use zstd rather than brotli for static file compression. 2025-08-18 04:51:46 +00:00
LeoVasanko bfcce1b80e Fix typing and import in the config file module. 2025-08-17 22:10:55 +00:00
LeoVasanko 2bd8d4a323 Startup banner with version display, and --version, using stderr/stdout properly. 2025-08-17 21:10:43 +00:00
LeoVasanko 1d97d48fed Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 19:21:03 +00:00
LeoVasanko f627890e55 Image previews improved, all EXIF Orientations handled. 2025-08-17 19:00:52 +00:00
LeoVasanko 5a5b47346f Cleanup for release 1.0.0. 2025-08-15 23:11:57 +00:00
LeoVasanko 64de17a74b Maintenance update (#7)
- Use modern tooling uv and bun
- Various changes to work with latest PyAV and PIL that have changed their API
- Improved image, video and document previews (uses AVIF, renders AVIF/HEIC/videos in HDR, faster processing)
- Fix a server hang in some cases where a folder was moved or renamed
- Log exceptions instead of only returning 500 response to client
- Log timing of preview generation functions
- Default to quality 50 in previews (previously 40)
2025-08-15 16:03:04 +00:00
LeoVasanko 55133e82f3 Better transparent Cista image 2023-11-25 17:25:35 +00:00
LeoVasanko 211da1cdda Correct hatch build hook 2023-11-21 20:11:37 +00:00
LeoVasanko d8972cee5d Less messy breadcrumbs on search results in gallery 2023-11-21 17:51:38 +00:00
LeoVasanko 2110284b76 Fix direct uploads and downloads, transfer bar UI 2023-11-21 16:11:54 +00:00
LeoVasanko fa0588837e Fixed New Folder, added Rename to Gallery 2023-11-21 15:49:33 +00:00
LeoVasanko 83bc1bea81 Various build fixes, cleanup and details (#6)
- Major memory usage reduction in video previews
- Finally builds properly on Windows too
2023-11-21 15:32:49 +00:00
LeoVasanko afc6c37d78 Cleanup 2023-11-21 08:35:34 +00:00
60 changed files with 3368 additions and 828 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
.* .*
*.lock
!.gitignore !.gitignore
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
/cista/wwwroot/* /cista/frontend-build/
/dist /dist
+48 -43
View File
@@ -6,6 +6,8 @@ Cista takes its name from the ancient *cistae*, metal containers used by Greeks
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**. This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter. The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible. All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
@@ -14,39 +16,31 @@ Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and
## Getting Started ## Getting Started
### Installation
To install the cista application, use:
```fish
pip install cista
```
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista`
### Running the Server ### Running the Server
Create an account: (or run a public server without authentication) We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
Create an account: (otherwise the server is public for all)
```fish ```fish
cista --user yourname --privileged uvx cista --user yourname --privileged
``` ```
Serve your files at http://localhost:8000: Serve your files at http://localhost:8000:
```fish ```fish
cista -l :8000 /path/to/files uvx cista -l :8000 /path/to/files
```
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
```fish
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.
### Internet Access ### Internet Access
To use your own TLS certificates, place them in the config folder and run: 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.
```fish
cista -l cista.example.com
```
Most admins instead 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.
`/etc/caddy/Caddyfile`: `/etc/caddy/Caddyfile`:
@@ -56,33 +50,13 @@ cista.example.com {
} }
``` ```
## Development setup Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
```fish
cd frontend
npm install
npm run dev
```
Concurrently, start the backend on another terminal:
```fish
hatch shell
pip install -e '.[dev]'
cista --dev -l :8000 /path/to/files
```
We use `hatch shell` for installing on a virtual environment, to avoid disturbing the rest of the system with our hacking.
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
## System Deployment ## System Deployment
This setup allows easy addition of storages, each with its own domain, configuration, and files. This setup allows easy addition of storages, each with its own domain, configuration, and files.
Assuming a restricted user account `storage` for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command. Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
Create `/etc/systemd/system/cista@.service`: Create `/etc/systemd/system/cista@.service`:
@@ -92,7 +66,7 @@ Description=Cista storage %i
[Service] [Service]
User=storage User=storage
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always Restart=always
[Install] [Install]
@@ -116,3 +90,34 @@ foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket reverse_proxy unix//srv/cista/{host}/socket
} }
``` ```
## Development setup
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
Make sure you have git, uv and bun (or npm) installed.
Backend (Python) setup and run:
```fish
git clone https://git.zi.fi/Vasanko/cista-storage.git
cd cista-storage
uv sync --dev
uv run cista --dev -l :8000 /path/to/files
```
Frontend (Vue/Vite) run the dev server in another terminal:
```fish
cd frontend
bun install
bun run dev
```
Building the package for release (frontend + Python wheel/sdist):
```fish
uv build
```
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
+51 -21
View File
@@ -1,3 +1,4 @@
import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -9,8 +10,24 @@ from cista.util import pwgen
del app, server80.app # Only import needed, for Sanic multiprocessing del app, server80.app # Only import needed, for Sanic multiprocessing
doc = f"""Cista {cista.__version__} - A file storage for the web.
def create_banner():
"""Create a framed banner with the Cista version."""
title = f"Cista {cista.__version__}"
subtitle = "A file storage for the web"
width = max(len(title), len(subtitle)) + 4
return f"""\
{"" * width}
{title:^{width}}
{subtitle:^{width}}
{"" * width}
"""
banner = create_banner()
doc = """\
Usage: Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>] cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password] cista [-c <confdir>] --user <name> [--privileged] [--password]
@@ -25,13 +42,25 @@ 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 = """\
No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts
cista -l :8000 /path/to/files # Run the server on localhost:8000
See cista --help for other options!
""" """
@@ -43,11 +72,19 @@ def main():
try: try:
return _main() return _main()
except Exception as e: except Exception as e:
print("Error:", e) sys.stderr.write(f"Error: {e}\n")
return 1 return 1
def _main(): def _main():
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
if any(arg in sys.argv for arg in ("--help", "-h")):
sys.stdout.write(banner)
elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n")
return 0
else:
sys.stderr.write(banner)
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
@@ -65,13 +102,7 @@ def _main():
necessary_opts = exists or import_droppy or path necessary_opts = exists or import_droppy or path
if not necessary_opts: if not necessary_opts:
# Maybe run without arguments # Maybe run without arguments
print(doc) sys.stderr.write(first_time_help)
print(
"No config file found! Get started with one of:\n"
" cista --user yourname --privileged\n"
" cista --import-droppy\n"
" cista -l :8000 /path/to/files\n"
)
return 1 return 1
settings = {} settings = {}
if import_droppy: if import_droppy:
@@ -80,6 +111,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:
@@ -88,11 +120,8 @@ 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)
print(f"Config {operation}: {config.conffile}") sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve # Prepare to serve
unix = None unix = None
url, _ = serve.parse_listen(config.config.listen) url, _ = serve.parse_listen(config.config.listen)
@@ -102,7 +131,7 @@ def _main():
dev = args["--dev"] dev = args["--dev"]
if dev: if dev:
extra += " (dev mode)" extra += " (dev mode)"
print(f"Serving {config.config.path} at {url}{extra}") sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0 return 0
@@ -117,7 +146,8 @@ def _confdir(args):
raise ValueError("Config path is not a directory") raise ValueError("Config path is not a directory")
# Accidentally pointed to the db.toml, use parent # Accidentally pointed to the db.toml, use parent
confdir = confdir.parent confdir = confdir.parent
config.conffile = confdir / config.conffile.name os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir() # Uses environ if available
def _user(args): def _user(args):
@@ -134,7 +164,7 @@ def _user(args):
"public": False, "public": False,
} }
) )
print(f"Config {operation}: {config.conffile}\n") sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
name = args["--user"] name = args["--user"]
if not name or not name.isidentifier(): if not name or not name.isidentifier():
@@ -152,12 +182,12 @@ def _user(args):
changes["password"] = pw = pwgen.generate() changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}\n" info += f"\n Password: {pw}\n"
res = config.update_user(name, changes) res = config.update_user(name, changes)
print(info) sys.stderr.write(f"{info}\n")
if res == "read": if res == "read":
print(" No changes") sys.stderr.write(" No changes\n")
if operation == "created": if operation == "created":
print( sys.stderr.write(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n" "Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
) )
+44 -11
View File
@@ -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
@@ -15,12 +16,12 @@ fileserver = FileServer()
@bp.before_server_start @bp.before_server_start
async def start_fileserver(app, _): async def start_fileserver(app):
await fileserver.start() await fileserver.start()
@bp.after_server_stop @bp.after_server_stop
async def stop_fileserver(app, _): async def stop_fileserver(app):
await fileserver.stop() await fileserver.stop()
@@ -92,6 +93,23 @@ 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_user := getattr(req.ctx, "sso_user", None):
# SSO auth (paskia mode): extract from validation response
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 +117,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()
) )
@@ -119,8 +133,12 @@ async def watch(req, ws):
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) await ws.send(await q.get())
except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket
raise
finally: finally:
del watching.pubsub[uuid] watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
def subscribe(uuid, ws): def subscribe(uuid, ws):
@@ -132,3 +150,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})
+57 -25
View File
@@ -3,20 +3,22 @@ import datetime
import mimetypes import mimetypes
import threading import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers import sanic.helpers
from blake3 import blake3 from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect from sanic import Blueprint, Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger from sanic.log import logger
from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor
from cista import auth, config, preview, session, watching from cista import auth, config, preview, session, 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
@@ -24,26 +26,38 @@ 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)
setproctitle("cista-main")
@app.before_server_start @app.before_server_start
async def main_start(app, loop): async def main_start(app):
config.load_config() config.load_config()
setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="cista-ioworker" max_workers=workers, thread_name_prefix="cista-ioworker"
) )
await watching.start(app, loop) watching.start(app)
@app.after_server_stop # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
async def main_stop(app, loop): @app.before_server_stop
async def main_stop(app):
quit.set() quit.set()
await watching.stop(app, loop) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
await sso.close_client()
logger.debug("Cista worker threads all finished")
@app.on_request @app.on_request
@@ -65,10 +79,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,
@@ -84,8 +111,9 @@ www = {}
def _load_wwwroot(www): def _load_wwwroot(www):
wwwnew = {} wwwnew = {}
base = Path(__file__).with_name("wwwroot") base = Path(__file__).with_name("frontend-build")
paths = [PurePath()] paths = [PurePath()]
zstd = ZstdCompressor(level=18)
while paths: while paths:
path = paths.pop(0) path = paths.pop(0)
current = base / path current = base / path
@@ -117,11 +145,11 @@ def _load_wwwroot(www):
else "no-cache", else "no-cache",
"content-type": mime, "content-type": mime,
} }
# Precompress with Brotli # Precompress with ZSTD
br = brotli.compress(data) zs = zstd.compress(data)
if len(br) >= len(data): if len(zs) >= len(data):
br = False zs = False
wwwnew[name] = data, br, headers wwwnew[name] = data, zs, headers
if not wwwnew: if not wwwnew:
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n" msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
if not www: if not www:
@@ -173,9 +201,9 @@ async def refresh_wwwroot():
for name in sorted(set(wwwold) - set(www)): for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n" changes += f"Deleted /{name}\n"
if changes: if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True) logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e: except Exception as e:
print(f"Error loading wwwroot: {e!r}") logger.error(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
@@ -187,21 +215,21 @@ async def wwwroot(req, path=""):
name = unquote(path) name = unquote(path)
if name not in www: if name not in www:
raise NotFound(f"File not found: /{path}", extra={"name": name}) raise NotFound(f"File not found: /{path}", extra={"name": name})
data, br, headers = www[name] data, zs, headers = www[name]
if req.headers.if_none_match == headers["etag"]: if req.headers.if_none_match == headers["etag"]:
# The client has it cached, respond 304 Not Modified # The client has it cached, respond 304 Not Modified
return empty(304, headers=headers) return empty(304, headers=headers)
# Brotli compressed? # Zstandard compressed?
if br and "br" in req.headers.accept_encoding.split(", "): if zs and "zstd" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "br"} headers = {**headers, "content-encoding": "zstd"}
data = br data = zs
return raw(data, headers=headers) return raw(data, headers=headers)
@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]]:
@@ -229,6 +257,10 @@ 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"""
if config.config.authentication == "paskia":
await auth.verify_sso(req)
else:
auth.verify(req)
wanted = set(keys.split("+")) wanted = set(keys.split("+"))
files = get_files(wanted) files = get_files(wanted)
+340 -35
View File
@@ -10,6 +10,175 @@ from sanic import Blueprint, html, json, redirect
from sanic.exceptions import BadRequest, Forbidden, Unauthorized from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session from cista import config, session
from cista.util import pwgen
_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})$")
@@ -62,62 +231,109 @@ 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), allows all requests.
All 401/403 responses include auth.iframe URL for consistent frontend handling
via the paskia library's showAuthIframe().
Args:
request: The Sanic request object
privileged: If True, requires admin privileges
Raises:
Unauthorized: If authentication is required
Forbidden: If access is denied
"""
sso = _get_sso()
if sso.paskia_enabled():
# SSO validation against auth backend
# Always check cista:login; privileged flag comes from response perm list
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:
if request.ctx.user.privileged: if 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,
)
elif config.config.public or user:
return return
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True) # Return iframe URL for paskia library to show login dialog
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:
@@ -148,7 +364,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"
@@ -191,3 +407,92 @@ async def change_password(request):
res = json({"message": "Password updated"}) res = json({"message": "Password updated"})
session.create(res, username) session.create(res, username)
return res return res
@bp.get("/users")
async def list_users(request):
await verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
{
"username": name,
"privileged": user.privileged,
"lastSeen": user.lastSeen,
}
)
return json({"users": users})
@bp.post("/users")
async def create_user(request):
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
password = request.json.get("password")
privileged = request.json.get("privileged", False)
else:
username = request.form["username"][0]
password = request.form.get("password", [None])[0]
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
if not username or not username.isidentifier():
raise ValueError("Invalid username")
except (KeyError, ValueError) as e:
raise BadRequest(str(e)) from e
if username in config.config.users:
raise BadRequest("User already exists")
if not password:
password = pwgen.generate()
changes = {"privileged": privileged}
changes["hash"] = _argon.hash(_pwnorm(password))
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} created", "password": password})
@bp.put("/users/<username>")
async def update_user(request, username):
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
else:
changes = {}
if "password" in request.form:
changes["password"] = request.form["password"][0]
if "privileged" in request.form:
changes["privileged"] = request.form["privileged"][0].lower() == "true"
except KeyError as e:
raise BadRequest("Missing fields") from e
password_response = None
if "password" in changes:
if changes["password"] == "":
changes["password"] = pwgen.generate()
password_response = changes["password"]
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
del changes["password"]
if not changes:
return json({"message": "No changes"})
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
response = {"message": f"User {username} updated"}
if password_response:
response["password"] = password_response
return json(response)
@bp.delete("/users/<username>")
async def delete_user(request, username):
await verify(request, privileged=True)
if username not in config.config.users:
raise BadRequest("User does not exist")
try:
config.del_user(username)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"})
+71 -25
View File
@@ -1,13 +1,17 @@
from __future__ import annotations from __future__ import annotations
import os
import secrets import secrets
import sys import sys
from contextlib import suppress
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
from time import time from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec
import msgspec import msgspec
import msgspec.toml
class Config(msgspec.Struct): class Config(msgspec.Struct):
@@ -20,6 +24,13 @@ class Config(msgspec.Struct):
links: dict[str, Link] = {} links: dict[str, Link] = {}
# Typing: arguments for config-modifying functions
P = ParamSpec("P")
ResultStr = Literal["modified", "created", "read"]
RawModifyFunc = Callable[Concatenate[Config, P], Config]
ModifyPublic = Callable[P, ResultStr]
class User(msgspec.Struct, omit_defaults=True): class User(msgspec.Struct, omit_defaults=True):
privileged: bool = False privileged: bool = False
hash: str = "" hash: str = ""
@@ -32,8 +43,24 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0 expires: int = 0
config = None # Global variables - initialized during application startup
conffile = Path.home() / ".local/share/cista/db.toml" config: Config
conffile: Path
def init_confdir() -> None:
global conffile
if p := os.environ.get("CISTA_HOME"):
home = Path(p)
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
home = (
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
)
if not home.is_dir():
home.mkdir(parents=True, exist_ok=True)
home.chmod(0o700)
conffile = home / "db.toml"
def derived_secret(*params, len=8) -> bytes: def derived_secret(*params, len=8) -> bytes:
@@ -59,10 +86,10 @@ def dec_hook(typ, obj):
raise TypeError raise TypeError
def config_update(modify): def config_update(
modify: RawModifyFunc,
) -> ResultStr | Literal["collision"]:
global config global config
if not conffile.exists():
conffile.parent.mkdir(parents=True, exist_ok=True)
tmpname = conffile.with_suffix(".tmp") tmpname = conffile.with_suffix(".tmp")
try: try:
f = tmpname.open("xb") f = tmpname.open("xb")
@@ -76,12 +103,8 @@ def config_update(modify):
old = conffile.read_bytes() old = conffile.read_bytes()
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook) c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError: except FileNotFoundError:
# No existing config file, make sure we have a folder...
confdir = conffile.parent
confdir.mkdir(parents=True, exist_ok=True)
confdir.chmod(0o700)
old = b"" old = b""
c = None c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
c = modify(c) c = modify(c)
new = msgspec.toml.encode(c, enc_hook=enc_hook) new = msgspec.toml.encode(c, enc_hook=enc_hook)
if old == new: if old == new:
@@ -92,7 +115,9 @@ def config_update(modify):
f.write(new) f.write(new)
f.close() f.close()
if sys.platform == "win32": if sys.platform == "win32":
conffile.unlink() # Windows doesn't support atomic replace # Windows doesn't support atomic replace
with suppress(FileNotFoundError):
conffile.unlink()
tmpname.rename(conffile) # Atomic replace tmpname.rename(conffile) # Atomic replace
except: except:
f.close() f.close()
@@ -102,17 +127,23 @@ def config_update(modify):
return "modified" if old else "created" return "modified" if old else "created"
def modifies_config(modify): def modifies_config(
"""Decorator for functions that modify the config file""" modify: Callable[Concatenate[Config, P], Config],
) -> Callable[P, ResultStr]:
"""Decorator for functions that modify the config file
The decorated function takes as first arg Config and returns it modified.
The wrapper handles atomic modification and returns a string indicating the result.
"""
@wraps(modify) @wraps(modify)
def wrapper(*args, **kwargs): def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
def m(c): def m(c: Config) -> Config:
return modify(c, *args, **kwargs) return modify(c, *args, **kwargs)
# Retry modification in case of write collision # Retry modification in case of write collision
while (c := config_update(m)) == "collision": while (c := config_update(m)) == "collision":
time.sleep(0.01) sleep(0.01)
return c return c
return wrapper return wrapper
@@ -120,14 +151,23 @@ def modifies_config(modify):
def load_config(): def load_config():
global config global config
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) init_confdir()
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
def update_config(conf: Config, changes: dict) -> Config: def update_config(conf: Config, changes: dict) -> Config:
"""Create/update the config with new values, respecting changes done by others.""" """Create/update the config with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config # Encode into dict, update values with new, convert to Config
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook) settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings.update(changes) settings.update(changes)
return msgspec.convert(settings, Config, dec_hook=dec_hook) return msgspec.convert(settings, Config, dec_hook=dec_hook)
@@ -137,8 +177,13 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
"""Create/update a user with new values, respecting changes done by others.""" """Create/update a user with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config # Encode into dict, update values with new, convert to Config
try: try:
u = conf.users[name].__copy__() # Copy user by converting to dict and back
except (KeyError, AttributeError): u = msgspec.convert(
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
User,
dec_hook=dec_hook,
)
except KeyError:
u = User() u = User()
if "password" in changes: if "password" in changes:
from . import auth from . import auth
@@ -147,7 +192,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
del changes["password"] del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook) udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes) udict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}} settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook) settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook) return msgspec.convert(settings, Config, dec_hook=dec_hook)
@@ -155,6 +200,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
@modifies_config @modifies_config
def del_user(conf: Config, name: str) -> Config: def del_user(conf: Config, name: str) -> Config:
"""Delete named user account.""" """Delete named user account."""
ret = conf.__copy__() # Create a copy by converting to dict and back
ret.users.pop(name) settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
return ret settings["users"].pop(name)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
+197 -44
View File
@@ -1,114 +1,267 @@
import asyncio import asyncio
import gc
import io import io
import mimetypes import mimetypes
import urllib.parse import urllib.parse
from pathlib import PurePosixPath from pathlib import PurePosixPath
from time import perf_counter
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import av import av
import av.datasets
import fitz # PyMuPDF import fitz # PyMuPDF
import numpy as np
import pillow_heif
from PIL import Image from PIL import Image
from sanic import Blueprint, empty, raw from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
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()
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
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}
@bp.get("/<path:path>") @bp.get("/<path:path>")
async def preview(req, path): async def preview(req, path):
"""Preview a file""" """Preview a file"""
maxsize = int(req.args.get("px", 1024)) maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 40)) quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path))) rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel filepath = config.config.path / rel
stat = path.lstat() stat = filepath.lstat()
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).hex()
savename = PurePosixPath(path.name).with_suffix(".webp") savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
"last-modified": format_date_time(stat.st_mtime), "last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable" "cache-control": "max-age=604800, immutable"
+ ("" if config.config.public else ", private"), + ("" if config.config.public else ", private"),
"content-type": "image/webp", "content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
} }
if req.headers.if_none_match == etag: if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified # The client has it cached, respond 304 Not Modified
return empty(304, headers=headers) return empty(304, headers=headers)
if not path.is_file(): if not filepath.is_file():
raise NotFound("File not found") raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor( img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
) )
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
return raw(img, headers=headers) return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
try:
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"): if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if mimetypes.guess_type(path.name)[0].startswith("video/"): type, _ = mimetypes.guess_type(path.name)
if type and type.startswith("video/"):
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:
logger.warning(f"Cannot generate preview for {path.name}: {e}")
except Exception as e:
logger.exception(f"Error generating preview for {path.name}: {e}")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
img = Image.open(path) t_load = perf_counter()
with Image.open(path) as img:
# Force decode to include I/O in load timing
img.load()
t_proc = perf_counter()
# Resize
w, h = img.size w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize))) img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Fix rotation based on EXIF data # Transpose pixels according to EXIF Orientation
try: orientation = img.getexif().get(274, 1)
rotate_values = {3: 180, 6: 270, 8: 90} if orientation in EXIF_ORI:
orientation = img._getexif().get(274) img = img.transpose(EXIF_ORI[orientation])
if orientation in rotate_values: # Save as AVIF
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
img = img.rotate(rotate_values[orientation], expand=True)
except AttributeError:
...
except Exception as e:
logger.error(f"Error rotating preview image: {e}")
# Save as webp
imgdata = io.BytesIO() imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=quality, method=4) t_save = perf_counter()
return imgdata.getvalue() img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_end = perf_counter()
ret = imgdata.getvalue()
load_ms = (t_proc - t_load) * 1000
proc_ms = (t_save - t_proc) * 1000
save_ms = (t_end - t_save) * 1000
logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
path.name,
load_ms,
proc_ms,
save_ms,
)
return ret
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0): def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
t_load_start = perf_counter()
pdf = fitz.open(path) pdf = fitz.open(path)
page = pdf.load_page(page_number) page = pdf.load_page(page_number)
w, h = page.rect[2:4] w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom) zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom) mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat) pix = page.get_pixmap(matrix=mat)
return pix.pil_tobytes(format="webp", quality=quality, method=4) t_load_end = perf_counter()
t_save_start = perf_counter()
ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1)
t_save_end = perf_counter()
logger.debug(
"Preview pdf %s: load+render=%.1fms save=%.1fms",
path.name,
(t_load_end - t_load_start) * 1000,
(t_save_end - t_save_start) * 1000,
)
return ret
def process_video(path, *, maxsize, quality): def process_video(path, *, maxsize, quality):
with av.open(str(path)) as container: frame = None
stream = container.streams.video[0]
rotation = (
stream.side_data
and stream.side_data.get(av.stream.SideData.DISPLAYMATRIX)
or 0
)
stream.codec_context.skip_frame = "NONKEY"
container.seek(container.duration // 8)
frame = next(container.decode(stream))
img = frame.to_image()
img.thumbnail((maxsize, maxsize))
imgdata = io.BytesIO() imgdata = io.BytesIO()
if rotation: istream = ostream = icc = occ = frame = None
img = img.rotate(rotation, expand=True) t_load_start = perf_counter()
img.save(imgdata, format="webp", quality=quality, method=4) # Initialize to avoid "possibly unbound" in static analysis when exceptions occur
return imgdata.getvalue() t_load_end = t_load_start
t_save_start = t_load_start
t_save_end = t_load_start
with (
av.open(str(path)) as icontainer,
av.open(imgdata, "w", format="avif") as ocontainer,
):
istream = icontainer.streams.video[0]
istream.codec_context.skip_frame = "NONKEY"
icontainer.seek((icontainer.duration or 0) // 8)
for frame in icontainer.decode(istream):
if frame.dts is not None:
break
else:
raise RuntimeError("No frames found in video")
# Resize frame to thumbnail size
if frame.width > maxsize or frame.height > maxsize:
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
new_width = int(frame.width * scale_factor)
new_height = int(frame.height * scale_factor)
frame = frame.reformat(width=new_width, height=new_height)
# Apply EXIF rotation if present
if frame.rotation:
# frame.rotation indicates clockwise rotation needed to display correctly
# np.rot90 rotates counter-clockwise, so we negate k
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
if k == 2:
# 180° rotation can be done in YUV420p, preserving HDR
try:
fplanes = frame.to_ndarray()
# Split into Y, U, V planes of proper dimensions
planes = [
fplanes[: frame.height],
fplanes[
frame.height : frame.height + frame.height // 4
].reshape(frame.height // 2, frame.width // 2),
fplanes[frame.height + frame.height // 4 :].reshape(
frame.height // 2, frame.width // 2
),
]
# Rotate each plane by 180°
planes = [np.rot90(p, 2) for p in planes]
# Restore PyAV format
planes = np.hstack([p.flat for p in planes]).reshape(
-1, planes[0].shape[1]
)
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
del planes, fplanes
except Exception as e:
logger.exception(f"Error rotating video frame by 180°: {e}")
elif k in (1, 3):
# 90° or 270° rotation requires RGB conversion (loses HDR)
try:
rgb = frame.to_ndarray(format="rgb24")
rgb = np.rot90(rgb, k)
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
frame = frame.reformat(
format="yuv420p"
) # Convert back for encoding
del rgb
except Exception as e:
logger.exception(
f"Error rotating video frame by {frame.rotation}°: {e}"
)
t_load_end = perf_counter()
t_save_start = perf_counter()
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
ostream = ocontainer.add_stream(
"av1",
options={
"crf": crf,
"usage": "realtime",
"cpu-used": "8",
"threads": "1",
},
)
assert isinstance(ostream, av.VideoStream)
ostream.width = frame.width
ostream.height = frame.height
ostream.pix_fmt = frame.format.name
icc = istream.codec_context
occ = ostream.codec_context
# Copy HDR metadata from input video stream
occ.color_primaries = icc.color_primaries
occ.color_trc = icc.color_trc
occ.colorspace = icc.colorspace
occ.color_range = icc.color_range
ocontainer.mux(ostream.encode(frame))
ocontainer.mux(ostream.encode(None)) # Flush the stream
t_save_end = perf_counter()
# Capture frame dimensions before cleanup
ret = imgdata.getvalue()
logger.debug(
"Preview video %s: load+decode=%.1fms save=%.1fms",
path.name,
(t_load_end - t_load_start) * 1000,
(t_save_end - t_save_start) * 1000,
)
del imgdata, istream, ostream, icc, occ, frame
gc.collect()
return ret
+1 -2
View File
@@ -127,8 +127,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
return f"{self.name} ({self.size}, {self.mtime})" return f"{self.name} ({self.size}, {self.mtime})"
class Update(msgspec.Struct, array_like=True): class Update(msgspec.Struct, array_like=True): ...
...
class UpdKeep(Update, tag="k"): class UpdKeep(Update, tag="k"):
-1
View File
@@ -26,7 +26,6 @@ def run(*, dev=False):
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
reload_dir={confdir},
access_log=True, access_log=True,
) # type: ignore ) # type: ignore
if dev: if dev:
+324
View File
@@ -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)
+7 -2
View File
@@ -29,10 +29,15 @@ async def handle_sanic_exception(request, e):
if not message or not request.app.debug and code == 500: if not message or not request.app.debug and code == 500:
message = "Internal Server Error" message = "Internal Server Error"
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
if code == 500:
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
@@ -50,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"
+2 -2
View File
@@ -1,4 +1,5 @@
from time import monotonic from time import monotonic
from typing import Callable
class LRUCache: class LRUCache:
@@ -12,7 +13,7 @@ class LRUCache:
cache (list): Internal list storing the cache items. cache (list): Internal list storing the cache items.
""" """
def __init__(self, open: callable, *, capacity: int, maxage: float): def __init__(self, open: Callable, *, capacity: int, maxage: float):
""" """
Initialize LRUCache. Initialize LRUCache.
@@ -50,7 +51,6 @@ class LRUCache:
# Add/restore to end of cache # Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic())) self.cache.insert(0, (key, f, monotonic()))
self.expire_items() self.expire_items()
print(self.cache)
return f return f
def expire_items(self): def expire_items(self):
+97 -34
View File
@@ -48,6 +48,7 @@ def treeiter(rootmod):
def treeget(rootmod: list[FileEntry], path: PurePosixPath): def treeget(rootmod: list[FileEntry], path: PurePosixPath):
begin = None begin = None
ret = [] ret = []
for i, relpath, entry in treeiter(rootmod): for i, relpath, entry in treeiter(rootmod):
if begin is None: if begin is None:
if relpath == path: if relpath == path:
@@ -57,6 +58,7 @@ def treeget(rootmod: list[FileEntry], path: PurePosixPath):
if entry.level <= len(path.parts): if entry.level <= len(path.parts):
break break
ret.append(entry) ret.append(entry)
return begin, ret return begin, ret
@@ -77,28 +79,36 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
# root # root
level += 1 level += 1
continue continue
ename = rel.parts[level - 1] ename = rel.parts[level - 1]
name = relpath.parts[level - 1] name = relpath.parts[level - 1]
esort = sortkey(ename) esort = sortkey(ename)
nsort = sortkey(name) nsort = sortkey(name)
# Non-leaf are always folders, only use relfile at leaf # Non-leaf are always folders, only use relfile at leaf
isfile = relfile if len(relpath.parts) == level else 0 isfile = relfile if len(relpath.parts) == level else 0
# First compare by isfile, then by sorting order and if that too matches then case sensitive # First compare by isfile, then by sorting order and if that too matches then case sensitive
cmp = ( cmp = (
entry.isfile - isfile entry.isfile - isfile
or (esort > nsort) - (esort < nsort) or (esort > nsort) - (esort < nsort)
or (ename > name) - (ename < name) or (ename > name) - (ename < name)
) )
if cmp > 0: if cmp > 0:
return i return i
if cmp < 0: if cmp < 0:
continue continue
level += 1 level += 1
if level > len(relpath.parts): if level > len(relpath.parts):
print("ERROR: insertpos", relpath, i, entry.name, entry.level, level) logger.error(
f"insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}"
)
break break
else: else:
i += 1 i += 1
return i return i
@@ -179,21 +189,16 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
"""Called on FS updates, check the filesystem and broadcast any changes.""" """Called on FS updates, check the filesystem and broadcast any changes."""
new = walk(relpath) new = walk(relpath)
obegin, old = treeget(rootmod, relpath) obegin, old = treeget(rootmod, relpath)
if old == new: if old == new:
logger.debug(
f"Watch: Event without changes needed {relpath}"
if old
else f"Watch: Event with old and new missing: {relpath}"
)
return return
if obegin is not None: if obegin is not None:
del rootmod[obegin : obegin + len(old)] del rootmod[obegin : obegin + len(old)]
if new: if new:
logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}")
i = treeinspos(rootmod, relpath, new[0].isfile) i = treeinspos(rootmod, relpath, new[0].isfile)
rootmod[i:i] = new rootmod[i:i] = new
else:
logger.debug(f"Watch: Removed {relpath}")
def update_space(loop): def update_space(loop):
@@ -218,17 +223,35 @@ def format_update(old, new):
oremain, nremain = set(old), set(new) oremain, nremain = set(old), set(new)
update = [] update = []
keep_count = 0 keep_count = 0
iteration_count = 0
# Precompute index maps to allow deterministic tie-breaking when both
# candidates exist in both sequences but are not equal (rename/move cases)
old_pos = {e: i for i, e in enumerate(old)}
new_pos = {e: i for i, e in enumerate(new)}
while oidx < len(old) and nidx < len(new): while oidx < len(old) and nidx < len(new):
iteration_count += 1
# Emergency brake for potential infinite loops
if iteration_count > 50000:
logger.error(
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
)
raise Exception(
f"format_update infinite loop detected at iteration {iteration_count}"
)
modified = False modified = False
# Matching entries are kept # Matching entries are kept
if old[oidx] == new[nidx]: if old[oidx] == new[nidx]:
entry = old[oidx] entry = old[oidx]
oremain.remove(entry) oremain.discard(entry)
nremain.remove(entry) nremain.discard(entry)
keep_count += 1 keep_count += 1
oidx += 1 oidx += 1
nidx += 1 nidx += 1
continue continue
if keep_count > 0: if keep_count > 0:
modified = True modified = True
update.append(UpdKeep(keep_count)) update.append(UpdKeep(keep_count))
@@ -248,7 +271,7 @@ def format_update(old, new):
insert_items = [] insert_items = []
while nidx < len(new) and new[nidx] not in oremain: while nidx < len(new) and new[nidx] not in oremain:
entry = new[nidx] entry = new[nidx]
nremain.remove(entry) nremain.discard(entry)
insert_items.append(entry) insert_items.append(entry)
nidx += 1 nidx += 1
if insert_items: if insert_items:
@@ -256,9 +279,32 @@ def format_update(old, new):
update.append(UpdIns(insert_items)) update.append(UpdIns(insert_items))
if not modified: if not modified:
raise Exception( # Tie-break: both items exist in both lists but don't match here.
f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}" # Decide whether to delete old[oidx] first or insert new[nidx] first
) # based on which alignment is closer.
if oidx >= len(old) or nidx >= len(new):
break
cur_old = old[oidx]
cur_new = new[nidx]
pos_old_in_new = new_pos.get(cur_old)
pos_new_in_old = old_pos.get(cur_new)
# Default distances if not present (shouldn't happen if in remain sets)
dist_del = (pos_old_in_new - nidx) if pos_old_in_new is not None else 1
dist_ins = (pos_new_in_old - oidx) if pos_new_in_old is not None else 1
# Prefer the operation with smaller forward distance; tie => delete
if dist_del <= dist_ins:
# Delete current old item
oremain.discard(cur_old)
update.append(UpdDel(1))
oidx += 1
else:
# Insert current new item
nremain.discard(cur_new)
update.append(UpdIns([cur_new]))
nidx += 1
# Diff any remaining # Diff any remaining
if keep_count > 0: if keep_count > 0:
@@ -311,10 +357,7 @@ def watcher_inotify(loop):
while not quit.is_set(): while not quit.is_set():
i = inotify.adapters.InotifyTree(rootpath.as_posix()) i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
t0 = time.perf_counter()
update_root(loop) update_root(loop)
t1 = time.perf_counter()
logger.debug(f"Root update took {t1 - t0:.1f}s")
trefresh = time.monotonic() + 300.0 trefresh = time.monotonic() + 300.0
tspace = time.monotonic() + 5.0 tspace = time.monotonic() + 5.0
# Watch for changes (frequent wakeups needed for quiting) # Watch for changes (frequent wakeups needed for quiting)
@@ -335,31 +378,51 @@ def watcher_inotify(loop):
if quit.is_set(): if quit.is_set():
return return
interesting = any(f in modified_flags for f in event[1]) interesting = any(f in modified_flags for f in event[1])
if event[2] == rootpath.as_posix() and event[3] == "zzz":
logger.debug(f"Watch: {interesting=} {event=}")
if interesting: if interesting:
# Update modified path # Update modified path
t0 = time.perf_counter()
path = PurePosixPath(event[2]) / event[3] path = PurePosixPath(event[2]) / event[3]
update_path(rootmod, path.relative_to(rootpath), loop) try:
t1 = time.perf_counter() rel_path = path.relative_to(rootpath)
logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s") update_path(rootmod, rel_path, loop)
except Exception as e:
logger.error(
f"Error processing inotify event for path {path}: {e}"
)
raise
if not dirty: if not dirty:
t = time.monotonic() t = time.monotonic()
dirty = True dirty = True
# Wait a maximum of 0.5s to push the updates # Wait a maximum of 0.2s to push the updates
if dirty and time.monotonic() >= t + 0.5: if dirty and time.monotonic() >= t + 0.2:
break break
if dirty and state.root != rootmod: if dirty and state.root != rootmod:
t0 = time.perf_counter() try:
update = format_update(state.root, rootmod) update = format_update(state.root, rootmod)
t1 = time.perf_counter()
with state.lock: with state.lock:
broadcast(update, loop) broadcast(update, loop)
state.root = rootmod state.root = rootmod
t2 = time.perf_counter() except Exception:
logger.debug( logger.exception(
f"Format update took {t1 - t0:.1f}s, broadcast {t2 - t1:.1f}s" "format_update failed; falling back to full rescan"
)
# Fallback: full rescan and try diff again; last resort send full root
try:
fresh = walk(PurePosixPath())
try:
update = format_update(state.root, fresh)
with state.lock:
broadcast(update, loop)
state.root = fresh
except Exception:
logger.exception(
"Fallback diff failed; sending full root snapshot"
)
with state.lock:
broadcast(format_root(fresh), loop)
state.root = fresh
except Exception:
logger.exception(
"Full rescan failed; dropping this batch of updates"
) )
del i # Free the inotify object del i # Free the inotify object
@@ -377,20 +440,20 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur) quit.wait(0.1 + 8 * dur)
async def start(app, loop): def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
use_inotify = sys.platform == "linux" use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll, target=watcher_inotify if use_inotify else watcher_poll,
args=[loop], args=[app.loop],
# Descriptive name for system monitoring # Descriptive name for system monitoring
name=f"cista-watcher {rootpath}", name=f"cista-watcher {rootpath}",
) )
app.ctx.watcher.start() app.ctx.watcher.start()
async def stop(app, loop): def stop(app):
quit.set() quit.set()
app.ctx.watcher.join() app.ctx.watcher.join()
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+2
View File
@@ -0,0 +1,2 @@
audit=false
fund=false
+6 -6
View File
@@ -22,25 +22,25 @@ If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has a
### Run the backend ### Run the backend
```fish ```fish
hatch shell uv sync --dev
cista --dev -l :8000 uv run cista --dev -l :8000
``` ```
### And the Vite server (in another terminal) ### And the Vite server (in another terminal)
```fish ```fish
cd frontend cd frontend
npm install bun install
npm run dev bun run dev
``` ```
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes. Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
### Type-Check, Compile and Minify for Production ### Type-Check, Compile and Minify for Production
This is also called by `hatch build` during Python packaging: This is also called by `uv build` during Python packaging:
```fish ```fish
npm run build bun run build
``` ```
+6
View File
@@ -1 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang=en> <html lang=en>
<meta charset=UTF-8> <meta charset=UTF-8>
<title>Cista Storage</title> <title>Cista Storage</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<link rel="icon" href="/src/assets/logo.svg"> <link rel="icon" href="/src/assets/logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+34 -31
View File
@@ -12,41 +12,44 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"engines": {
"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-plugin-rewrite-all": "^1.0.1", "unplugin-vue-components": "^31.0.0",
"vite-svg-loader": "^4.0.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.3.4", "vue": "^3.5.27",
"vue-router": "^4.2.4" "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,
+33 -3
View File
@@ -1,6 +1,10 @@
<template> <template>
<LoginModal /> <div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''">
{{ store.error }}
</div>
<SettingsModal /> <SettingsModal />
<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" />
@@ -10,6 +14,10 @@
<main> <main>
<RouterView :path="path.pathList" :query="path.query" /> <RouterView :path="path.pathList" :query="path.query" />
</main> </main>
<footer>
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -24,6 +32,8 @@ import { computed } from 'vue'
import Router from '@/router/index' import Router from '@/router/index'
import type { SortOrder } from './utils/docsort' import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue' import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
interface Path { interface Path {
path: string path: string
@@ -33,10 +43,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
} }
@@ -152,3 +162,23 @@ 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;
}
</style>
+11 -6
View File
@@ -91,8 +91,7 @@
} }
.headermain, .headermain,
.menu, .menu,
.rename-button, .rename-button {
.suggest-gallery {
display: none !important; display: none !important;
} }
.breadcrumb > a { .breadcrumb > a {
@@ -111,6 +110,7 @@
margin: 0 .5rem 0 1rem !important; margin: 0 .5rem 0 1rem !important;
} }
body#app { body#app {
position: static !important;
height: auto !important; height: auto !important;
} }
main { main {
@@ -166,6 +166,11 @@ body {
font-family: 'Roboto'; font-family: 'Roboto';
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */
overflow: hidden;
/* Fallback for older browsers */
height: 100vh;
height: 100dvh;
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
@@ -215,12 +220,14 @@ table {
gap: 0; gap: 0;
} }
body#app { body#app {
height: 100vh; position: fixed;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
main { main {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; /* Allow flex child to shrink below content size */
padding-bottom: 3em; /* convenience space on the bottom */ padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
text-align: center; text-align: center;
@@ -238,6 +245,7 @@ header nav.headermain {
z-index: 101; z-index: 101;
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
pointer-events: none;
font-size: 1rem; font-size: 1rem;
text-align: center; text-align: center;
padding: .5rem 1rem; padding: .5rem 1rem;
@@ -249,9 +257,6 @@ header nav.headermain {
white-space: pre; white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards; animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
} }
.modified [data-tooltip]:hover:after {
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
}
@keyframes appearbriefly { @keyframes appearbriefly {
from { from {
opacity: 0; opacity: 0;
+137
View File
@@ -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>
+4 -3
View File
@@ -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) }
+132
View File
@@ -0,0 +1,132 @@
<template>
<Teleport to="body">
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot>
</div>
</Teleport>
</template>
<script lang="ts">
// Global activation state - shared across all instances
let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
// Track if we've seen real mouse movement (not touch-simulated)
let hasRealMouse = false
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
text: string
delay?: number
}>()
const visible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
let 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(() => ({
left: `${mouseX.value}px`,
top: `${mouseY.value}px`,
}))
// Check if the device likely has a real mouse (fine pointer)
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches
const showTooltip = () => {
visible.value = true
globalActive = true
}
const scheduleTooltip = () => {
if (settleTimer) clearTimeout(settleTimer)
if (globalDeactivateTimer) {
clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = null
}
const delay = globalActive ? 0 : (props.delay ?? 900)
settleTimer = setTimeout(showTooltip, 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) => {
// 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
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 = () => {
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
visible.value = false
// Deactivate global state after a short delay if no new tooltip started
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = setTimeout(() => {
globalActive = false
}, 400)
}
defineExpose({
startHover,
updatePosition,
endHover,
})
</script>
<style scoped>
.cursor-tooltip {
position: fixed;
z-index: 10000;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: nowrap;
pointer-events: none;
font-size: 1rem;
}
</style>
+31 -25
View File
@@ -1,10 +1,10 @@
<template> <template>
<SvgButton name="download" data-tooltip="Download" @click="download" /> <SvgButton name="download" data-tooltip="Download" @click="download" />
<TransferBar :status=progress @cancel=cancelDownloads />
</template> </template>
<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 { reactive } from 'vue'; import { reactive } from 'vue';
@@ -26,22 +26,22 @@ const status_init = {
filepos: 0, filepos: 0,
status: 'idle', status: 'idle',
} }
const progress = reactive({...status_init}) store.dprogress = {...status_init}
setInterval(() => { setInterval(() => {
if (Date.now() - progress.tlast > 3000) { if (Date.now() - store.dprogress.tlast > 3000) {
// Reset // Reset
progress.statbytes = 0 store.dprogress.statbytes = 0
progress.statdur = 1 store.dprogress.statdur = 1
} else { } else {
// Running average by decay // Running average by decay
progress.statbytes *= .9 store.dprogress.statbytes *= .9
progress.statdur *= .9 store.dprogress.statdur *= .9
} }
}, 100) }, 100)
const statReset = () => { const statReset = () => {
Object.assign(progress, status_init) Object.assign(store.dprogress, status_init)
progress.t0 = Date.now() store.dprogress.t0 = Date.now()
progress.tlast = progress.t0 + 1 store.dprogress.tlast = store.dprogress.t0 + 1
} }
const cancelDownloads = () => { const cancelDownloads = () => {
location.reload() // FIXME location.reload() // FIXME
@@ -61,9 +61,9 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
console.log('Downloading to filesystem', sel.recursive) console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) { for (const [rel, full, doc] of sel.recursive) {
if (doc.dir) continue if (doc.dir) continue
progress.files.push(rel) store.dprogress.files.push(rel)
++progress.filecount ++store.dprogress.filecount
progress.total += doc.size store.dprogress.total += doc.size
} }
for (const [rel, full, doc] of sel.recursive) { for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories // Create any missing directories
@@ -73,12 +73,13 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
} }
const r = rel.slice(hdir.length) const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) { for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
if (!dir) continue
hdir += `${dir}/` hdir += `${dir}/`
try { try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true }) h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create directory', hdir, error) console.error('Failed to create directory', hdir, error)
return throw new Error(`Failed to create directory ${hdir}: ${error}`)
} }
console.log('Created', hdir) console.log('Created', hdir)
} }
@@ -90,37 +91,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
fileHandle = await h.getFileHandle(name, { create: true }) fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error) console.error('Failed to create file', rel, full, hdir + name, error)
return throw new Error(`Failed to create file ${hdir + name}: ${error}`)
} }
try {
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}`)
} }
if (res.body) { if (res.body) {
++progress.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.error = "Direct download."
progress.tlast = Date.now() store.dprogress.tlast = Date.now()
while (true) { while (true) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
if (done) break if (done) break
await writable.write(value) await writable.write(value)
const now = Date.now() const now = Date.now()
const size = value.byteLength const size = value.byteLength
progress.xfer += size store.dprogress.xfer += size
progress.filepos += size store.dprogress.filepos += size
progress.statbytes += size store.dprogress.statbytes += size
progress.statdur += now - progress.tlast store.dprogress.statdur += now - store.dprogress.tlast
progress.tlast = now store.dprogress.tlast = now
} }
} }
await writable.close() await writable.close()
console.log('Saved', hdir + name) console.log('Saved', hdir + name)
} catch (error) {
console.error('Failed to write file', hdir + name, error)
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
}
} }
statReset() statReset()
} }
@@ -139,7 +145,7 @@ const download = async () => {
if (files.length === 1) { if (files.length === 1) {
store.selected.clear() store.selected.clear()
store.error = "Single file via browser downloads" store.error = "Single file via browser downloads"
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) {
@@ -158,7 +164,7 @@ 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 name = sel.keys.length === 1 ? sel.docs[sel.keys[0]!]!.name : 'download'
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.error = "Downloading as ZIP via browser downloads"
store.selected.clear() store.selected.clear()
+8 -4
View File
@@ -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>
@@ -10,11 +11,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import cog from '@/assets/svg/cog.svg' import { Cog } from '@/assets/svg'
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[],
@@ -35,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>
+7 -5
View File
@@ -17,7 +17,7 @@
<td class="name"> <td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td> </td>
<FileModified :doc=editing :key=nowkey /> <FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing /> <FileSize :doc=editing />
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
@@ -55,7 +55,7 @@
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button> <button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template> </template>
</td> </td>
<FileModified :doc=doc :key=nowkey /> <FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc /> <FileSize :doc=doc />
<td class="menu"> <td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button> <button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
@@ -115,6 +115,7 @@ const rename = (doc: Doc, newName: string) => {
} }
defineExpose({ defineExpose({
newFolder() { newFolder() {
console.log("New folder")
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
loc: loc.value, loc: loc.value,
@@ -124,6 +125,7 @@ defineExpose({
mtime: now, mtime: now,
size: 0, size: 0,
}) })
store.cursor = editing.value.key
}, },
toggleSelectAll() { toggleSelectAll() {
console.log('Select') console.log('Select')
@@ -173,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)
} }
@@ -253,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: () => {
+25 -8
View File
@@ -1,22 +1,39 @@
<template> <template>
<td class="modified right"> <td class="modified right">
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time> <time
:datetime=datetime
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>{{ modified }}</time>
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</td> </td>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed } from 'vue' import { formatUnixDate } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
doc: Doc
now: number
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
// Reference props.now to trigger reactivity when time updates
const modified = computed(() => {
props.now // trigger reactivity
return formatUnixDate(props.doc.mtime)
})
const datetime = computed(() => const datetime = computed(() =>
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z') new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
) )
const tooltip = computed(() => const tooltipText = computed(() =>
datetime.value.replace('T', '\n').replace('Z', ' UTC') datetime.value.replace('T', ' ').replace('Z', ' UTC')
) )
const props = defineProps<{
doc: Doc
}>()
</script> </script>
+1 -1
View File
@@ -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
}) })
+28 -13
View File
@@ -2,9 +2,8 @@
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key> <template v-for="(doc, index) in documents" :key=doc.key>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null"> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<BreadCrumb v-if=showFolderBreadcrumb(index) :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/> <GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
</GalleryFigure>
</template> </template>
</div> </div>
</template> </template>
@@ -52,10 +51,12 @@ const rename = (doc: Doc, newName: string) => {
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columns = computed(() => { const columnCount = ref(1)
if (!gallery.value) return 1 const updateColumns = () => {
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length if (!gallery.value) return
}) columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
}
const columns = computed(() => columnCount.value)
defineExpose({ defineExpose({
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -67,6 +68,7 @@ defineExpose({
mtime: now, mtime: now,
size: 0, size: 0,
}) })
store.cursor = editing.value.key
}, },
toggleSelectAll() { toggleSelectAll() {
console.log('Select') console.log('Select')
@@ -127,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)
} }
@@ -164,12 +166,21 @@ watchEffect(() => {
focusBreadcrumb() focusBreadcrumb()
} }
}) })
let resizeObserver: ResizeObserver | null = null
onMounted(() => { onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus() active.focus()
} }
updateColumns()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
}) })
const mkdir = (doc: Doc, name: string) => { const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, { const control = connect(controlUrl, {
@@ -198,9 +209,11 @@ 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: () => {
return ( return (
@@ -250,10 +263,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
grid-template-rows: repeat(minmax(auto, 15em));
align-items: end; align-items: end;
} }
.breadcrumb { .folder-indicator {
border-radius: .5em; grid-column: 1 / -1;
}
.folder-start {
grid-column-start: 1;
} }
</style> </style>
+25 -4
View File
@@ -4,30 +4,38 @@
@contextmenu.stop @contextmenu.stop
@focus.stop="store.cursor = doc.key" @focus.stop="store.cursor = doc.key"
@click=onclick @click=onclick
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
> >
<figure> <figure>
<slot></slot> <slot></slot>
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" /> <MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
<div class="titlespacer"></div> <div class="titlespacer"></div>
<figcaption @click.prevent> <figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
<template v-if="editing"> <template v-if="editing">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit /> <FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
</template> </template>
<template v-else> <template v-else>
<SelectBox :doc=doc @click="store.cursor = doc.key"/> <SelectBox :doc=doc @click="store.cursor = doc.key"/>
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span> <span>{{ doc.name }}</span>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
</figcaption> </figcaption>
</figure> </figure>
<CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
</CursorTooltip>
</a> </a>
</template> </template>
<script setup lang=ts> <script setup lang=ts>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue' import MediaPreview from '@/components/MediaPreview.vue'
import CursorTooltip from './CursorTooltip.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
@@ -40,6 +48,9 @@ const props = defineProps<{
editing?: EditingProp, editing?: EditingProp,
}>() }>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = computed(() => props.doc.key)
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) ev.preventDefault()
@@ -48,6 +59,13 @@ const onclick = (ev: Event) => {
</script> </script>
<style scoped> <style scoped>
.tooltip-name {
font-weight: 600;
text-align: center;
}
.tooltip-details {
text-align: center;
}
figure { figure {
max-height: 15em; max-height: 15em;
position: relative; position: relative;
@@ -57,12 +75,15 @@ figure {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: end; justify-content: center;
overflow: hidden; overflow: hidden;
} }
figure > article { figure > article {
flex: 0 0 auto; flex: 0 0 auto;
} }
figure :deep(.video-container) {
height: 15em;
}
.titlespacer { .titlespacer {
flex-shrink: 100000; flex-shrink: 100000;
width: 100%; width: 100%;
+35 -9
View File
@@ -1,14 +1,10 @@
<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"
data-tooltip="New folder" data-tooltip="New folder"
@click="() => store.fileExplorer!.newFolder()" @click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
/> />
<slot></slot> <slot></slot>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
@@ -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
@@ -73,11 +74,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: 'Settings', onClick: () => { store.dialog = 'settings' }})
// For external auth, show user name as link to /auth/
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 (!ssoStore.isExternalAuth) {
items.push({ label: 'Login', onClick: () => store.loginDialog() }) // Show login in paskia iframe overlay
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/restricted')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
}})
} }
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
+1 -1
View File
@@ -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
}) })
} }
-101
View File
@@ -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>
+46 -3
View File
@@ -2,7 +2,10 @@
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt=""> <img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
<img v-else-if=doc.img :src=doc.url alt=""> <img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <div v-else-if=video() class="video-container">
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<div class="play-overlay"><PlayIcon /></div>
</div>
<div v-else-if=audio() class="audio icon"> <div v-else-if=audio() class="audio icon">
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio> <audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
</div> </div>
@@ -13,6 +16,7 @@
<script setup lang=ts> <script setup lang=ts>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { 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)
@@ -42,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
} }
} }
@@ -101,7 +105,7 @@ const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext) const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext) const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
const preview = () => ( const preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) || ['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
props.doc.size > 500000 && props.doc.size > 500000 &&
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext) ['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
) )
@@ -165,4 +169,43 @@ img::before {
filter: grayscale(1); filter: grayscale(1);
content: '❌'; content: '❌';
} }
.video-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 50%;
max-width: 100%;
max-height: 100%;
}
.video-container video {
width: 100%;
height: 100%;
border-radius: calc(.5em / 8);
object-fit: contain;
}
.play-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
width: 4em;
height: 4em;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.play-overlay svg {
width: 2em;
height: 2em;
fill: white;
margin-left: 0.25em; /* Visual centering for play triangle */
}
.video-container:hover .play-overlay {
transform: scale(1.1);
}
video[data-playing] + .play-overlay {
opacity: 0;
}
</style> </style>
+232 -43
View File
@@ -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>
+7 -43
View File
@@ -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.error = '⚠️ 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.error = 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>
+4 -6
View File
@@ -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>
+11 -4
View File
@@ -57,13 +57,12 @@ const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value <
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: var(--primary-color); color: var(--primary-color);
position: fixed; width: 100%;
left: 0;
bottom: 0;
width: 100vw;
} }
.statustext { .statustext {
display: flex; display: flex;
align-items: center;
margin: 0 .5em;
padding: 0.5rem 0; padding: 0.5rem 0;
} }
span { span {
@@ -84,4 +83,12 @@ span {
.position { min-width: 4em } .position { min-width: 4em }
.speed { min-width: 4em } .speed { min-width: 4em }
.upload .statustext::before {
font-size: 1.5em;
content: '🔺'
}
.download .statustext::before {
font-size: 1.5em;
content: '🔻'
}
</style> </style>
+38 -38
View File
@@ -1,3 +1,12 @@
<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
</template>
<script setup lang="ts"> <script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS'; import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
@@ -108,50 +117,50 @@ const uprogress_init = {
filepos: 0, filepos: 0,
status: 'idle', status: 'idle',
} }
const uprogress = reactive({...uprogress_init}) store.uprogress = {...uprogress_init}
setInterval(() => { setInterval(() => {
if (Date.now() - uprogress.tlast > 3000) { if (Date.now() - store.uprogress.tlast > 3000) {
// Reset // Reset
uprogress.statbytes = 0 store.uprogress.statbytes = 0
uprogress.statdur = 1 store.uprogress.statdur = 1
} else { } else {
// Running average by decay // Running average by decay
uprogress.statbytes *= .9 store.uprogress.statbytes *= .9
uprogress.statdur *= .9 store.uprogress.statdur *= .9
} }
}, 100) }, 100)
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => { const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
if (name !== uprogress.filename) return // If stats have been reset if (name !== store.uprogress.filename) return // If stats have been reset
const now = Date.now() const now = Date.now()
uprogress.xfer = uprogress.filestart + end store.uprogress.xfer = store.uprogress.filestart + end
uprogress.filepos = end store.uprogress.filepos = end
uprogress.statbytes += end - start store.uprogress.statbytes += end - start
uprogress.statdur += now - uprogress.tlast store.uprogress.statdur += now - store.uprogress.tlast
uprogress.tlast = now store.uprogress.tlast = now
// File finished? // File finished?
if (end === size) { if (end === size) {
uprogress.filestart += size store.uprogress.filestart += size
statNextFile() statNextFile()
if (++uprogress.fileidx >= uprogress.filecount) statReset() if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
} }
} }
const statNextFile = () => { const statNextFile = () => {
const f = uprogress.files.shift() const f = store.uprogress.files.shift()
if (!f) return statReset() if (!f) return statReset()
uprogress.filepos = 0 store.uprogress.filepos = 0
uprogress.filesize = f.file.size store.uprogress.filesize = f.file.size
uprogress.filename = f.cloudName store.uprogress.filename = f.cloudName
} }
const statReset = () => { const statReset = () => {
Object.assign(uprogress, uprogress_init) Object.assign(store.uprogress, uprogress_init)
uprogress.t0 = Date.now() store.uprogress.t0 = Date.now()
uprogress.tlast = uprogress.t0 + 1 store.uprogress.tlast = store.uprogress.t0 + 1
} }
const statsAdd = (f: CloudFile[]) => { const statsAdd = (f: CloudFile[]) => {
if (uprogress.files.length === 0) statReset() if (store.uprogress.files.length === 0) statReset()
uprogress.total += f.reduce((a, b) => a + b.file.size, 0) store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
uprogress.filecount += f.length store.uprogress.filecount += f.length
uprogress.files = [...uprogress.files, ...f] store.uprogress.files = [...store.uprogress.files, ...f]
statNextFile() statNextFile()
} }
let upqueue = [] as CloudFile[] let upqueue = [] as CloudFile[]
@@ -181,7 +190,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
// @ts-ignore // @ts-ignore
ws.sendData = async (data: any) => { ws.sendData = async (data: any) => {
// Wait until the WS is ready to send another message // Wait until the WS is ready to send another message
uprogress.status = "uploading" store.uprogress.status = "uploading"
await new Promise(resolve => { await new Promise(resolve => {
const t = setInterval(() => { const t = setInterval(() => {
if (ws.bufferedAmount > 1<<20) return if (ws.bufferedAmount > 1<<20) return
@@ -189,14 +198,14 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
clearInterval(t) clearInterval(t)
}, 1) }, 1)
}) })
uprogress.status = "processing" store.uprogress.status = "processing"
ws.send(data) ws.send(data)
} }
}) })
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 }
@@ -210,7 +219,7 @@ const worker = async () => {
if (f.cloudPos === f.file.size) upqueue.shift() if (f.cloudPos === f.file.size) upqueue.shift()
} }
if (upqueue.length) startWorker() if (upqueue.length) startWorker()
uprogress.status = "idle" store.uprogress.status = "idle"
workerRunning = false workerRunning = false
} }
let workerRunning: any = false let workerRunning: any = false
@@ -233,12 +242,3 @@ onUnmounted(() => {
removeEventListener('drop', uploadHandler) removeEventListener('drop', uploadHandler)
}) })
</script> </script>
<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
<TransferBar :status=uprogress @cancel=cancelUploads />
</template>
@@ -0,0 +1,228 @@
<template>
<ModalDialog name=usermgmt title="Admin Settings">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<label for="publicAccess">
<input
type="checkbox"
id="publicAccess"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
Public access (anyone can read and write)
</label>
</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>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
</div>
<table>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td>{{ user.username }}</td>
<td>
<input
type="checkbox"
:checked="user.privileged"
@change="toggleAdmin(user, $event)"
:disabled="user.username === store.user.username"
/>
</td>
<td>
<button @click="renameUser(user)" class="button small" title="Rename user"></button>
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button>
</td>
</tr>
</tbody>
</table>
</template>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
interface User {
username: string
privileged: boolean
lastSeen: number
}
const store = useMainStore()
const loading = ref(true)
const users = ref<User[]>([])
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
public: false
})
const close = () => {
store.dialog = ''
success.value = ''
}
const loadUsers = async () => {
try {
loading.value = true
const data = await listUsers()
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to load users'
} finally {
loading.value = false
}
}
const addUser = async () => {
const username = window.prompt('Enter username for new user:')
if (!username || !username.trim()) return
try {
success.value = ''
const result = await createUser(username.trim(), undefined, false)
await loadUsers()
if (result.password) {
success.value = `User ${username.trim()} created. Password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to add user'
}
}
const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement
try {
await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update user'
target.checked = user.privileged // revert
}
}
const renameUser = async (user: User) => {
const newName = window.prompt('Enter new username:', user.username)
if (!newName || !newName.trim() || newName.trim() === user.username) return
// For rename, we need to create new user and delete old, or have a rename endpoint
// Since no rename endpoint, perhaps delete and create
try {
success.value = ''
const result = await createUser(newName.trim(), undefined, user.privileged)
await deleteUser(user.username)
await loadUsers()
if (result.password) {
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to rename user'
}
}
const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
try {
success.value = ''
const result = await updateUser(user.username, { password: "" })
if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to reset password'
}
}
const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return
try {
await deleteUser(username)
await loadUsers()
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to delete user'
}
}
const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1]!)
if (isButtonClick) {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
success.value = ''
copyButtonText.value = '📋'
}, 3000)
} else {
// Just hide the message when clicking elsewhere
success.value = ''
}
}
}
const updateServerSettings = async () => {
try {
success.value = ''
await updatePublic(serverSettings.public)
// Update store
store.server.public = serverSettings.public
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update settings'
}
}
onMounted(() => {
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()
}
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal || false
})
</script>
<style scoped>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+67 -16
View File
@@ -1,21 +1,71 @@
class ClientClass { import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, { // Type for API error responses
method: 'POST', interface ApiError {
headers: { error: {
accept: 'application/json', code: number
'content-type': 'application/json' message: string
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
} }
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message) }
function hasError(msg: unknown): msg is ApiError {
return typeof msg === 'object' && msg !== null && 'error' in msg
}
class ClientClass {
async get(url: string): Promise<any> {
try {
const msg = await apiJson(url, { method: 'GET' })
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 post(url: string, data?: Record<string, any>): Promise<any> {
try {
const msg = await apiJson(url, {
method: 'POST',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
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> {
try {
const msg = await apiJson(url, {
method: 'PUT',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
}
async delete(url: string): Promise<any> {
try {
const msg = await apiJson(url, { method: 'DELETE' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
} }
} }
@@ -32,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
} }
} }
export { apiFetch }
export default Client export default Client
+16 -8
View File
@@ -12,15 +12,20 @@ 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 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 // Use setter for validation
}
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}`)
@@ -37,21 +42,24 @@ export class Doc {
return this.url.replace(/^\/#/, '') return this.url.replace(/^\/#/, '')
} }
get img(): boolean { get img(): boolean {
const ext = this.name.split('.').pop()?.toLowerCase() // Folders cannot be images
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '') if (this.dir) return false
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
} }
get previewable(): boolean { get previewable(): boolean {
// Folders cannot be previewable
if (this.dir) return false
if (this.img) return true if (this.img) return true
const ext = this.name.split('.').pop()?.toLowerCase()
// Not a comprehensive list, but good enough for now // Not a comprehensive list, but good enough for now
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(ext || '') return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
} }
get previewurl(): string { get previewurl(): string {
return this.url.replace(/^\/files/, '/preview') return this.url.replace(/^\/files/, '/preview')
} }
get ext(): string { get ext(): string {
const ext = this.name.split('.').pop() const dotIndex = this.name.lastIndexOf('.')
return ext ? ext.toLowerCase() : '' if (dotIndex === -1 || dotIndex === this.name.length - 1) return ''
return this.name.slice(dotIndex + 1).toLowerCase()
} }
} }
export type errorEvent = { export type errorEvent = {
+34 -3
View File
@@ -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, {
@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
}) })
return data return data
} }
export const url_users = '/auth/users'
export async function listUsers() {
const data = await Client.get(url_users)
return data
}
export async function createUser(username: string, password?: string, privileged?: boolean) {
const data = await Client.post(url_users, {
username,
password,
privileged
})
return data
}
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
const data = await Client.put(`${url_users}/${username}`, changes)
return data
}
export async function deleteUser(username: string) {
const data = await Client.delete(`${url_users}/${username}`)
return data
}
export async function updatePublic(isPublic: boolean) {
const data = await Client.put('/api/config/public', { public: isPublic })
return data
}
+67 -12
View File
@@ -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
} }
} }
+42 -18
View File
@@ -2,23 +2,23 @@ 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'
export const useMainStore = defineStore({ export const useMainStore = defineStore('main', {
id: 'main',
state: () => ({ state: () => ({
document: shallowRef<Doc[]>([]), document: [] as Doc[],
selected: new Set<FUID>([]), selected: new Set<FUID>([]),
query: '' as string, query: '' as string,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, error: '' as string,
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', dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
uprogress: {} as any,
dprogress: {} as any,
prefs: { prefs: {
gallery: false, gallery: false,
sortListing: '' as SortOrder, sortListing: '' as SortOrder,
@@ -31,7 +31,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)
@@ -67,17 +67,35 @@ export const useMainStore = defineStore({
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
@@ -89,7 +107,13 @@ export const useMainStore = defineStore({
}, },
focusBreadcrumb() { focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus() (document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
} },
cancelDownloads() {
location.reload() // FIXME
},
cancelUploads() {
location.reload() // FIXME
},
}, },
getters: { getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
@@ -121,7 +145,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)
+19
View File
@@ -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 }
})
+47
View File
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
sorted.sort(ordering[order]) sorted.sort(ordering[order])
return sorted return sorted
} }
/**
* Sort documents while keeping files grouped by their folder.
* - name: folders sorted by folder path, items within by name
* - modified: folders sorted by newest item within results, items within by mtime
* - size: folders sorted by largest file within results, items within by size
*/
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
if (!order) return documents
const compare = ordering[order]
// Group documents by their folder location
const byFolder = new Map<string, Doc[]>()
for (const doc of documents) {
const folder = doc.loc
if (!byFolder.has(folder)) byFolder.set(folder, [])
byFolder.get(folder)!.push(doc)
}
// Sort items within each folder
for (const docs of byFolder.values()) {
docs.sort(compare)
}
// Find the "best" item in each folder (first after sorting = best according to criteria)
const folderBest = new Map<string, Doc>()
for (const [folder, docs] of byFolder) {
folderBest.set(folder, docs[0]!)
}
// Sort folders: by path for name sort, by best item for modified/size
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
if (order === 'name') {
return collator.compare(a, b)
}
return compare(folderBest.get(a)!, folderBest.get(b)!)
})
// Flatten back into a single array with folder grouping preserved
const result: Doc[] = []
for (const folder of sortedFolders) {
result.push(...byFolder.get(folder)!)
}
return result
}
+8 -8
View File
@@ -50,12 +50,11 @@ export function formatUnixDate(t: number) {
} }
export function getFileExtension(filename: string) { export function getFileExtension(filename: string) {
const parts = filename.split('.') const dotIndex = filename.lastIndexOf('.')
if (parts.length > 1) { if (dotIndex === -1 || dotIndex === filename.length - 1) {
return parts[parts.length - 1] return '' // No extension
} else {
return '' // No hay extensión
} }
return filename.slice(dotIndex + 1)
} }
interface FileTypes { interface FileTypes {
[key: string]: string[] [key: string]: string[]
@@ -68,9 +67,10 @@ const filetypes: FileTypes = {
} }
export function getFileType(name: string): string { export function getFileType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() const dotIndex = name.lastIndexOf('.')
if (!ext || ext.length === name.length) return 'unknown' if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown' const ext = name.slice(dotIndex + 1).toLowerCase()
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
} }
// Prebuilt for fast & consistent sorting // Prebuilt for fast & consistent sorting
+11 -20
View File
@@ -13,21 +13,16 @@
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<div v-if="!store.prefs.gallery && documents.some(doc => doc.previewable)" class="suggest-gallery">
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }"></SvgButton>
Gallery View
</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 } 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 Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils' import { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
import cog from '@/assets/svg/cog.svg'
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -54,9 +49,9 @@ const documents = computed(() => {
} }
} }
const locsub = loc + '/' const locsub = loc + '/'
// Custom sort override in effect? // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
if (order) return sorted(docs, order) if (order) return sortedGrouped(docs, order)
// Sort by relevance - current folder, then subfolders, then others // Sort by relevance - current folder, then subfolders, then others
docs.sort((a, b) => ( docs.sort((a, b) => (
// @ts-ignore // @ts-ignore
@@ -77,6 +72,13 @@ watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
store.query = props.query store.query = props.query
}) })
// Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true })
</script> </script>
<style scoped> <style scoped>
@@ -90,15 +92,4 @@ watchEffect(() => {
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);
} }
.suggest-gallery p {
font-size: 2rem;
color: var(--accent-color);
}
.suggest-gallery {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style> </style>
+35
View File
@@ -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 -25
View File
@@ -1,27 +1,19 @@
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'
// @ts-ignore // @ts-ignore
import pluginRewriteAll from 'vite-plugin-rewrite-all'
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
// Development mode:
// npm run dev # Run frontend that proxies to dev_backend
// cista -l :8000 --dev # Run backend
const dev_backend = {
target: "http://localhost:8000",
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(),
pluginRewriteAll(),
svgLoader(), // import svg files svgLoader(), // import svg files
Components(), // auto import components Components(), // auto import components
], ],
@@ -38,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/wwwroot", rollupOptions: {
emptyOutDir: true, output: {
} manualChunks: {
// Bundle all SVG icons into a single chunk
icons: [
'/src/assets/svg/index.ts',
],
},
},
},
},
}) })
+112 -56
View File
@@ -10,25 +10,42 @@ readme = "README.md"
authors = [ authors = [
{ name = "Vasanko" }, { name = "Vasanko" },
] ]
maintainers = [
{ name = "Vasanko" },
]
keywords = ["file-server", "web-interface", "dropbox", "storage"]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Intended Audience :: End Users/Desktop",
"Intended Audience :: System Administrators",
"License :: Public Domain",
"License :: OSI Approved :: MIT License",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"argon2-cffi", "argon2-cffi>=25.1.0",
"blake3", "av>=15.0.0",
"brotli", "blake3>=1.0.5",
"docopt", "docopt>=0.6.2",
"inotify", "fastapi-vue>=0.5.1",
"msgspec", "fastapi[standard]>=0.128.0",
"natsort", "html5tagger>=1.3.0",
"pathvalidate", "httpx>=0.28.0",
"pillow", "inotify>=0.2.12",
"pyav", "msgspec>=0.19.0",
"pyjwt", "natsort>=8.4.0",
"pymupdf", "numpy>=2.3.2",
"sanic", "pathvalidate>=3.3.1",
"stream-zip", "pillow>=11.3.0",
"tomli_w", "pillow-heif>=1.1.0",
"pyjwt>=2.10.1",
"pymupdf>=1.26.3",
"sanic>=25.12.0",
"setproctitle>=1.3.6",
"stream-zip>=0.0.83",
"tomli_w>=1.2.0",
"zstandard>=0.24.0",
] ]
[project.urls] [project.urls]
@@ -39,66 +56,105 @@ cista = "cista.__main__:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest", "pytest>=8.4.1",
"ruff", "ruff>=0.8.0",
"mypy>=1.13.0",
"pre-commit>=4.0.0",
]
test = [
"pytest>=8.4.1",
"pytest-cov>=6.0.0",
"pytest-asyncio>=0.25.0",
]
docs = [
"sphinx>=8.0.0",
"sphinx-rtd-theme>=3.0.0",
] ]
[tool.hatch.version] [tool.hatch.version]
source = "vcs" source = "vcs"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["cista/wwwroot"] artifacts = ["cista/frontend-build"]
hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
targets.sdist.include = [
"/cista",
]
hooks.vcs.version-file = "cista/_version.py" hooks.vcs.version-file = "cista/_version.py"
hooks.vcs.template = """ hooks.vcs.template = """
# This file is automatically generated by hatch build. # This file is automatically generated by hatch build.
__version__ = {version!r} __version__ = {version!r}
""" """
only-packages = true only-packages = true
targets.sdist.include = [ packages = ["cista"]
"/cista",
]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = [ addopts = [
"--import-mode=importlib", "--import-mode=importlib",
"--verbosity=-1", "--verbosity=2",
"-p no:warnings", "--strict-markers",
"--strict-config",
"--cov=cista",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-branch",
] ]
testpaths = [ testpaths = ["tests"]
"tests", python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
filterwarnings = [
"error",
"ignore::UserWarning",
"ignore::DeprecationWarning",
] ]
[tool.ruff] [tool.ruff.lint]
select = ["ALL"] isort.known-first-party = ["cista"]
ignore = [ per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
"A0", per-file-ignores."scripts/*" = ["T20"]
"ARG001",
"ANN", [dependency-groups]
"B018", dev = [
"BLE001", "pytest>=8.4.1",
"C901", "ruff>=0.8.0",
"COM812", # conflicts with ruff format "mypy>=1.13.0",
"D", "pre-commit>=4.0.0",
"E501", "httpx>=0.28.1",
"EM1", ]
"FIX002", test = [
"ISC001", # conflicts with ruff format "pytest>=8.4.1",
"PGH003", "pytest-cov>=6.0.0",
"PLR0912", "pytest-asyncio>=0.25.0",
"PLR2004",
"PLW0603",
"S101",
"SLF001",
"T201",
"TD0",
"TRY",
] ]
show-source = true
show-fixes = true
[tool.ruff.isort] [tool.coverage.run]
known-first-party = ["cista"] source = ["cista"]
branch = true
omit = [
"*/tests/*",
"*/test_*",
"*/__pycache__/*",
"cista/_version.py",
]
[tool.ruff.per-file-ignores] [tool.coverage.report]
"tests/*" = ["S", "ANN", "D", "INP"] exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]
show_missing = true
skip_covered = false
precision = 2
-12
View File
@@ -1,12 +0,0 @@
# noqa: INP001
import subprocess
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
print("Building Cista frontend...")
subprocess.run("npm install --prefix frontend".split(" "), check=True) # noqa: S603
subprocess.run("npm run build --prefix frontend".split(" "), check=True) # noqa: S603
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [frontend] [--backend backend]
Options:
frontend Vite frontend endpoint (default: localhost:5173)
--backend Cista backend endpoint (default: from config, or :8000)
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
"""
import argparse
import asyncio
import contextlib
import os
import sys
from pathlib import Path
# 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.serve import parse_listen
DEFAULT_BACKEND_PORT = 8000
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
"""Parse backend listen address and build cista dev command.
Returns (url, cmd).
"""
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
async def run_devserver(frontend: str | None, backend: str | None) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
if not (front / "package.json").exists():
logger.warning("Frontend source not found at %s", front)
raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "")
backend_url, sanic_cmd = setup_sanic_backend(backend)
# Tell vite where to proxy API requests
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
async with ProcessGroup() as pg:
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))
# 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)
await pg.spawn(*vite, cwd=str(front))
def main():
parser = argparse.ArgumentParser(
description="Run Vite and Cista (Sanic) development servers",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=HELP_EPILOG,
)
parser.add_argument(
"frontend",
nargs="?",
metavar="host:port",
help="Vite frontend endpoint (default: localhost:5173)",
)
parser.add_argument(
"--backend",
"-l",
metavar="host:port",
help="Cista backend endpoint (default: from config, or :8000)",
)
args = parser.parse_args()
with contextlib.suppress(KeyboardInterrupt):
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__":
main()
+15
View File
@@ -0,0 +1,15 @@
"""Hatch build hook for building Vue frontend during package build."""
import sys
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
build("frontend")
+191
View File
@@ -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)
+157
View File
@@ -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