Compare commits

...
96 Commits
Author SHA1 Message Date
LeoVasanko f1e16b7abe Preview: add pdf+pyvips encode path and simplify timings 2026-04-25 00:20:19 +00:00
LeoVasanko 0ebff0ec17 Preview: simplify pyvips timing to single processing ms + req total. 2026-04-25 00:06:35 +00:00
LeoVasanko c8ab06d864 Frontend: don't show img preview for video/audio/doc files
- MediaPreview.vue: only render <img> for non-video, non-audio items
  (video/audio already have their own player containers)
- Document.ts: limit previewurl to previewable files (was checking only .complete)
2026-04-24 23:54:02 +00:00
LeoVasanko d5ff7757c8 Preview: persistent worker pool, fast AVIF encode, access-log timing
- Add preview_worker.py: long-lived subprocess; JSONL request / framed binary
  response protocol (BLAKE3 checksum + LE uint32 sizes + msgspec JSON + payload)
- Add _PreviewWorkerPool: asyncio queue-based pool; kill+replace workers on timeout,
  checksum error, or protocol error; PREVIEW_TIMEOUT=3s, PREVIEW_WORKERS=cpu-scaled
- Use pyvips effort=0,strip=True for fast AVIF thumbnails; CISTA_PIL=1 env forces Pillow
- Report load/process/save ms in PreviewResponse; inject into access log via req.ctx._log_extra
- Wire start/shutdown_preview_workers into Sanic lifecycle hooks (app.py)
- Call configure_main_logging() after Sanic() to install emoji formatter
- Silence worker stderr (DEVNULL)
2026-04-24 23:53:23 +00:00
LeoVasanko cd604eb10a Logging: emoji formatter and access-log extra field 2026-04-24 23:52:34 +00:00
LeoVasanko abcf5d9940 Use client IP (potentially proxied) in logs, handle [ipv6]. 2026-04-24 20:13:11 +00:00
LeoVasanko 0190bda853 Avoid useless check. 2026-04-24 00:04:58 +00:00
LeoVasanko fc48500412 Upload end of file first for large files (for preview to function). 2026-04-24 00:02:17 +00:00
LeoVasanko d31ad0b525 Avoid showing previews while file upload is in progress or the file is incomplete. This avoids 500 errors on server trying to generate the preview. 2026-04-23 19:34:16 +00:00
LeoVasanko da4bba95be Handle missing preview files with silent 404 2026-04-23 19:02:54 +00:00
LeoVasanko e07ab220cb Add cleaner access logging for HTTP requests and WebSockets 2026-04-23 18:06:22 +00:00
LeoVasanko 3da2f6e6c3 Don't show broken media previews during uploads, instead show a spinner indicating completion. 2026-04-23 17:40:03 +00:00
LeoVasanko ded7ce65bc Faster image previews by pyvips. 2026-04-23 17:19:24 +00:00
LeoVasanko 17550be698 Allow duplicate selected entries of the same name from different paths. 2026-04-23 17:01:03 +00:00
LeoVasanko 0b269aef7f Set avif=1 flag for SVT-AV1 when encoding a single image, for increased efficiency and less memory usage. 2026-04-23 16:57:27 +00:00
LeoVasanko 497de296f2 Consistent forwarding of host/origin/ua over HTTP and WS to Paskia. 2026-02-10 23:56:42 +00:00
LeoVasanko 134b216f4c Cleanup 2026-02-10 23:52:07 +00:00
LeoVasanko 06759b3c12 pytest deps, remove unused test group. 2026-02-10 23:46:44 +00:00
LeoVasanko c51552ea29 Cleaner WebSocket/Paskia proxying. 2026-02-10 23:42:13 +00:00
LeoVasanko 00645fc8ff Attempt to get allocated size on Windows which needs WINAPI. Fallback to file size if not possible. 2026-02-05 20:31:46 +00:00
LeoVasanko 760f7bc35d Fix vite argument passing on deno (fastapi-vue-setup upstream). 2026-02-05 20:13:03 +00:00
LeoVasanko 8480a73839 Fix --listen docopt parsing (only -l was working). 2026-02-05 20:01:26 +00:00
LeoVasanko 302ed684e7 Remove invalid Sanic kwarg when binding to all interfaces (not needed anyway). 2026-02-05 19:56:54 +00:00
LeoVasanko af35e0480a Better proxy header processing, pass original user-agent without httpx overriding it. 2026-02-05 17:53:52 +00:00
LeoVasanko 5717486197 Intl support for search hotkey (where US keyboard has it). 2026-02-05 00:57:42 +00:00
LeoVasanko 0061fc54ae Keyboard navigation fixes (still not perfect but better). 2026-02-05 00:54:15 +00:00
LeoVasanko 4eefe83072 Fix search bar losing focus under specific conditions, mainly that when the search takes more than 50ms, causing results to be emptied, with a file priorly focused, was causing breadcrumbs to get focused. (reactivity is hard) 2026-02-05 00:07:45 +00:00
LeoVasanko f578a50007 Pass the new allocated field through search filtering. Fixes issue where 0 allocated was reported in search results. 2026-02-04 23:55:05 +00:00
LeoVasanko f40d9c1abd Cache previews in server RAM for much faster access (they are about 70kB each). 2026-02-04 20:56:16 +00:00
LeoVasanko 3d8845cf99 Brighter low free space colors. 2026-02-04 20:40:59 +00:00
LeoVasanko 87e1443e7d Auth restricted endpoint is picky about the trailing slash, use it consistently. 2026-02-04 20:35:29 +00:00
LeoVasanko f45c57e901 Auth mode indication on startupbox 2026-02-04 20:24:21 +00:00
LeoVasanko 41686d1dd1 Correctly handle customized server name in admin settings dialog. 2026-02-04 20:15:56 +00:00
LeoVasanko cc351bb992 Space usage widget show full name if possible, allow longer names. 2026-02-04 20:12:49 +00:00
LeoVasanko c3abbe0a3b Rename HeaderSelected to SelectionToolbar. 2026-02-04 20:01:05 +00:00
LeoVasanko 127caeedea Improved selection toolbar UX. 2026-02-04 19:57:31 +00:00
LeoVasanko 113bc56351 README 2026-02-04 19:23:48 +00:00
LeoVasanko c7727c72d9 Make devserver script take --listen argument for vite, while forwarding other args correctly to backend 2026-02-04 19:23:34 +00:00
LeoVasanko 85b3aa6b81 Consistent header and footer sizing. 2026-02-04 19:19:41 +00:00
LeoVasanko 62b44ddb43 Fix free/used disk space handling. Implement file disk usage tracking. Display indicators on FileExplorer for incomplete files (sparse allocation, upload in progress). 2026-02-04 18:54:54 +00:00
LeoVasanko 60a53ef3d3 Implement server name config to override the default of using share folder name. 2026-02-04 18:00:21 +00:00
LeoVasanko acd38c2235 Punch a hole in the pie to make a donut. 2026-02-04 17:27:56 +00:00
LeoVasanko 2f38f15afa Unused imports 2026-02-04 17:09:14 +00:00
LeoVasanko 0fc28e56bb Don't allow header scale larger with the window above 1000px, while the rest of the app keeps scaling by root font size. 2026-02-04 17:08:32 +00:00
LeoVasanko 22b0e503e0 Revisited diskspace widget animations and styling. 2026-02-04 17:01:22 +00:00
LeoVasanko e3a4ecdcc2 More consistent and properly scaling header layout and positioning. Search keyboard tooltip hidden for mobile users. 2026-02-04 16:13:03 +00:00
LeoVasanko 9f363e3f66 New much prettier disk space widget. 2026-02-04 15:14:06 +00:00
LeoVasanko 8270dd0cc2 Refactor to make full file list completely non-reactive because pinia persistence was causing long delays especially while searching when there were a lot of files. Implement better ghosts that do not alter the file list. 2026-02-04 02:03:22 +00:00
LeoVasanko 1af6cd82fe Prebuild lookup structures to reduce UI lag on very large file lists. 2026-02-04 00:18:28 +00:00
LeoVasanko 0bc2a12cfa Make devserver script pass extra args to cista CLI. 2026-02-04 00:01:02 +00:00
LeoVasanko 9e07a3f40d Require new fastapi-vue for unix socket compatibility. 2026-02-01 07:10:21 +00:00
LeoVasanko 2867b1075d Disk space pie chart. 2026-02-01 06:59:15 +00:00
LeoVasanko 4fc8e29cc1 Implement a larger set of file menu options. 2026-02-01 06:33:34 +00:00
LeoVasanko 55f9117b3e Remove refresh wwwroot support and static files serving, only using vite to serve frontend in dev mode (avoid accidentally using stale builds). 2026-02-01 06:14:35 +00:00
LeoVasanko 2b6746c55e Change default port to 8989 like Droppy. Using 8999 for backend in dev mode. 2026-02-01 06:09:56 +00:00
LeoVasanko 1ed3779933 Use fastapi-vue parse_endpoint instead of our own logic. 2026-02-01 06:00:30 +00:00
LeoVasanko a95cf7be94 Better UI feedback on search order changes. 2026-02-01 05:48:15 +00:00
LeoVasanko d18501f633 Fix a search caching issue. 2026-02-01 05:38:48 +00:00
LeoVasanko ed697db871 New header/footer layout and styles. Use cursorTooltip in more places, make it prettier. Fix tooltip still sometimes activating with touch (now only with mouse). 2026-02-01 05:19:42 +00:00
LeoVasanko 856e8c4cc8 Remove download to folder, just download zip or single file. The folder download is still accessible by Alt+Click on the download button (in the few browsers that support it). 2026-02-01 03:47:14 +00:00
LeoVasanko f8a6eacb06 Automatically navigate into newly uploaded folders. 2026-02-01 03:27:13 +00:00
LeoVasanko e9a82e84ad Implement ghost items reflecting pending changes from frontend (e.g. files being uploaded) until the server acknowledges them. This gives immediate UI feedback and is clearer in cases. 2026-02-01 03:23:43 +00:00
LeoVasanko ccd05b53f4 Remove dead code. 2026-02-01 02:49:50 +00:00
LeoVasanko 032ad13b97 Unified watcher that *may* receive events from inotify and other sources. Added change notify messages from control and upload WebSockets. Cleanup debug printouts. 2026-02-01 02:44:52 +00:00
LeoVasanko 4eae75c84b New inotify watcher with efficient algorithms. 2026-01-31 23:46:43 +00:00
LeoVasanko 252b31a293 Performance profile file change watching. 2026-01-31 22:53:17 +00:00
LeoVasanko feff202161 Debouncing to avoid UI flicker while typing in search. 2026-01-31 22:42:16 +00:00
LeoVasanko d15bfc86c4 Search optimizations and fixes. Prevent re-renders on hash change (redux). Clear previous search results at the start of a new search. 2026-01-31 22:37:47 +00:00
LeoVasanko f94ccc01c6 Optimizing the search faster; don't trigger full re render by URL changes. 2026-01-31 22:17:05 +00:00
LeoVasanko bc355d991d Simplified startup box unix socket formatting. 2026-01-31 22:09:57 +00:00
LeoVasanko e7ebad3c36 Upgrade to docopt-ng because docopt is no longer maintained. 2026-01-31 22:06:03 +00:00
LeoVasanko 036c4342de Better error logging on preview generation errors. 2026-01-31 22:00:26 +00:00
LeoVasanko 160f929e0c Basic details in startup box. 2026-01-31 21:53:39 +00:00
LeoVasanko 1970d40d8a Implement background worker to make search response faster and avoid hanging the UI when there are a lot of files. Implement better toast messages for transitional events that automatically disappear. 2026-01-31 21:02:16 +00:00
LeoVasanko 1a164e0a08 Smarter ZIP download naming. 2026-01-31 20:59:43 +00:00
LeoVasanko 46d222006a More robust zip download processing; don't let aborted requests hang threads, use a larger threadpool separate of preview generation. 2026-01-31 19:43:23 +00:00
LeoVasanko 98949e6b30 Update README for Paskia SSO 2026-01-31 05:15:52 +00:00
LeoVasanko 8f76d770ee Fixes for public mode authentication flows. 2026-01-31 04:48:44 +00:00
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
65 changed files with 6257 additions and 1344 deletions
+1 -1
View File
@@ -4,5 +4,5 @@
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
/cista/wwwroot/* /cista/frontend-build/
/dist /dist
+175 -123
View File
@@ -1,123 +1,175 @@
# Cista Web Storage # Cista Web Storage
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250> <img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake. Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
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. **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 was not being developed at the time. Now they have picked up pace too, feel free to try both and compare.
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.
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something... Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
## Getting Started ## Getting Started
### Running the Server ### Running the Server
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista: 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) Try it out locally at http://localhost:8000 (serves the current directory):
```fish ```fish
uvx cista --user yourname --privileged uvx cista
``` ```
Serve your files at http://localhost:8000: Create an account: (otherwise the server is public for all)
```fish ```fish
uvx cista -l :8000 /path/to/files uvx cista --user yourname --privileged
``` ```
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`. Serve your files at http://localhost:8000:
```fish
```fish uvx cista -l :8000 /path/to/files
pip install cista --break-system-packages ```
```
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
```fish
### Internet Access pip install cista --break-system-packages
```
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.
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
`/etc/caddy/Caddyfile`:
## Authentication
```Caddyfile
cista.example.com { Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
reverse_proxy :8000
} ### Public Mode
```
In public mode, anyone can read, send and even delete files without without logging in. Users entering the service won't be asked to authenticate. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
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`
### Built-in Password Authentication (default)
## System Deployment
User accounts are managed directly by Cista. Create users with the `--user` flag:
This setup allows easy addition of storages, each with its own domain, configuration, and files.
```fish
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. uvx cista --user admin --privileged # Create admin user
uvx cista --user guest # Create regular user
Create `/etc/systemd/system/cista@.service`: ```
```ini Privileged users can manage other users and change settings via the Admin Settings menu.
[Unit]
Description=Cista storage %i ### Passkey Authentication and SSO
[Service] For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. This allows user account and permission management at the corporate level, without bothering Cista with it.
User=storage
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i Set the `PASKIA_BACKEND_URL` environment variable:
Restart=always
```fish
[Install] PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
WantedBy=multi-user.target ```
```
Run the Paskia backend on the same machine (to use that default URL):
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience. ```fish
uvx paskia
```fish ```
systemctl daemon-reload
systemctl enable --now cista@foo.example.com In Paskia mode:
systemctl enable --now cista@bar.example.com - All `/auth/*` requests are proxied to the Paskia backend
``` - Cista backend verifies access by `/auth/api/validate` endpoint and shows a login dialog if needed
- Users with `cista:login` permission can access files
Public exposure is easiest using the Caddy web server. - Users with `cista:admin` permission get privileged access (Admin Settings)
`/etc/caddy/Caddyfile`: ### Internet Access
```Caddyfile 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.
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket `/etc/caddy/Caddyfile`:
}
``` ```Caddyfile
cista.example.com {
## Development setup reverse_proxy :8000
}
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. 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`
Backend (Python) setup and run: ## System Deployment
```fish This setup allows easy addition of storages, each with its own domain, configuration, and files.
git clone https://git.zi.fi/Vasanko/cista-storage.git
cd cista-storage 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 javascript runtimes.
uv sync --dev
uv run cista --dev -l :8000 /path/to/files Create (edit) a systemd unit:
```
```fish
Frontend (Vue/Vite) run the dev server in another terminal: sudo systemctl edit --force --full cista@.service
```
```fish
cd frontend Paste the following:
bun install
bun run dev ```ini
``` [Unit]
Description=Cista storage %i
Building the package for release (frontend + Python wheel/sdist):
[Service]
```fish User=storage
uv build ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
``` Restart=always
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
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.
[Install]
WantedBy=multi-user.target
```
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
```fish
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Public exposure is easiest using the Caddy web server.
`/etc/caddy/Caddyfile`:
```Caddyfile
foo.example.com, bar.example.com {
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/frontend-build`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
+62 -22
View File
@@ -25,36 +25,72 @@ def create_banner():
""" """
def create_startup_box(
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
):
"""Create a framed startup box with server information."""
title = f"Cista {cista.__version__}"
listen = unix if unix else url
location = f"{folder} @ {listen}"
lines = [title, location]
# Auth line: Paskia <url> or Password, with optional Public suffix
if paskia_url:
auth_line = f"Auth: Paskia {paskia_url}"
else:
auth_line = "Auth: Password"
if public:
auth_line += ", Public"
lines.append(auth_line)
if dev:
lines.append("dev mode")
# Calculate width based on content
inner_width = max(len(line) for line in lines) + 2
# Build the box
box = [f"{'' * inner_width}"]
for line in lines:
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n"
banner = create_banner() banner = create_banner()
doc = """\ doc = """\
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]
cista --version
Options: Options:
-c CONFDIR Custom config directory -c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on -l, --listen LISTEN-ADDR
:8000 (localhost port, plain http) Listen on
:8989 (localhost port, plain http)
<addr>:3000 (bind another address, port) <addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket) /path/to/unix.sock (unix socket)
example.com (run on 80 and 443 with LetsEncrypt) example.com (run on 80 and 443 with LetsEncrypt)
--import-droppy Import Droppy config from ~/.droppy/config --import-droppy Import Droppy config from ~/.droppy/config
--dev Developer mode (reloads, friendlier crashes, more logs) --dev Developer mode (reloads, friendlier crashes, more logs)
Listen address, path and imported options are preserved in config, and only Listen address and path are preserved in config,
custom config dir and dev mode need to be specified on subsequent runs. and only config dir and dev mode need to be specified on subsequent runs.
User management: User management:
--user NAME Create or modify user --user NAME Create or modify user
--privileged Give the user full admin rights --privileged Give the user full admin rights
--password Reset password --password Reset password
Environment:
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
https://git.zi.fi/leovasanko/paskia
""" """
first_time_help = """\ first_time_help = """\
No config file found! Get started with: No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts cista --user yourname --privileged # If you want user accounts
cista -l :8000 /path/to/files # Run the server on localhost:8000 cista -l :8989 /path/to/files # Run the server on localhost:8989
See cista --help for other options! See cista --help for other options!
""" """
@@ -79,12 +115,11 @@ def _main():
elif "--version" in sys.argv: elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n") sys.stdout.write(f"cista {cista.__version__}\n")
return 0 return 0
else: # Don't print banner yet for normal startup - we'll print the startup box later
sys.stderr.write(banner)
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
listen = args["-l"] listen = args["--listen"]
# Validate arguments first # Validate arguments first
if args["<path>"]: if args["<path>"]:
path = Path(args["<path>"]).resolve() path = Path(args["<path>"]).resolve()
@@ -107,6 +142,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:
@@ -114,22 +150,26 @@ def _main():
if listen: if listen:
settings["listen"] = listen settings["listen"] = listen
elif not exists: elif not exists:
settings["listen"] = ":8000" settings["listen"] = ":8989"
if not exists and not import_droppy: config.update_config(settings)
# We have no users, so make it public
settings["public"] = True
operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve # Prepare to serve
unix = None url, opts = serve.parse_listen(config.config.listen)
url, _ = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir(): if not config.config.path.is_dir():
raise ValueError(f"No such directory: {config.config.path}") raise ValueError(f"No such directory: {config.config.path}")
extra = f" ({unix})" if unix else ""
dev = args["--dev"] dev = args["--dev"]
if dev: # Check for Paskia SSO
extra += " (dev mode)" from cista.sso import PASKIA_BACKEND_URL
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Print startup box
startup_box = create_startup_box(
folder=config.config.path,
url=url,
unix=opts.get("unix"),
dev=dev,
paskia_url=PASKIA_BACKEND_URL or None,
public=config.config.public,
)
sys.stderr.write(startup_box)
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0 return 0
@@ -157,7 +197,7 @@ def _user(args):
# Defaults for new config when user is created # Defaults for new config when user is created
operation = config.update_config( operation = config.update_config(
{ {
"listen": ":8000", "listen": ":8989",
"path": Path.home() / "Downloads", "path": Path.home() / "Downloads",
"public": False, "public": False,
} }
@@ -186,7 +226,7 @@ def _user(args):
if operation == "created": if operation == "created":
sys.stderr.write( 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 :8989 ~/Downloads\n"
) )
+78 -10
View File
@@ -1,11 +1,13 @@
import asyncio import asyncio
import typing import typing
from pathlib import PurePosixPath
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 +17,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()
@@ -52,6 +54,9 @@ async def upload(req, ws):
if pos != req.end: if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
# Signal the watcher about the uploaded file and its parent directories
path = PurePosixPath(req.name)
watching.notify_change(path, *path.parents)
# Report success # Report success
res = StatusMsg(status="ack", req=req) res = StatusMsg(status="ack", req=req)
await asend(ws, res) await asend(ws, res)
@@ -86,12 +91,36 @@ async def control(req, ws):
while True: while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes) cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(cmd) await asyncio.to_thread(cmd)
# Signal the watcher about affected paths
watching.notify_change(*cmd.affected_paths())
await asend(ws, StatusMsg(status="ack", req=cmd)) await asend(ws, StatusMsg(status="ack", req=cmd))
@bp.websocket("watch") @bp.websocket("watch")
@websocket_wrapper @websocket_wrapper
async def watch(req, ws): async def watch(req, ws):
# Build user info from either built-in auth or SSO
user_info = None
if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode)
try:
await sso.validate_sso_request(req)
except Exception:
pass # Ignore auth errors, user_info stays None
if sso_user := getattr(req.ctx, "sso_user", None):
ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", [])
user_info = {
"username": ctx.get("user", {}).get("display_name", ""),
"privileged": "cista:admin" in perms,
}
elif req.ctx.user:
# Built-in auth: use local user database
user_info = {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
await ws.send( await ws.send(
msgspec.json.encode( msgspec.json.encode(
{ {
@@ -99,13 +128,9 @@ async def watch(req, ws):
"name": config.config.name or config.config.path.name, "name": config.config.name or config.config.path.name,
"version": __version__, "version": __version__,
"public": config.config.public, "public": config.config.public,
"paskia": sso.paskia_enabled(),
}, },
"user": { "user": user_info,
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
if req.ctx.user
else None,
} }
).decode() ).decode()
) )
@@ -136,3 +161,46 @@ 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.get("config")
async def get_config(request):
await auth.verify(request, privileged=True)
return json(
{
"name": config.config.name,
"public": config.config.public,
}
)
@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})
@bp.put("config/name")
async def update_name(request):
await auth.verify(request, privileged=True)
try:
name = request.json["name"]
if not isinstance(name, str):
raise ValueError("name must be a string")
except KeyError:
raise BadRequest("Missing name field") from None
except ValueError as e:
raise BadRequest(str(e)) from None
config.update_config({"name": name})
# Return the effective name (fallback to path.name if empty)
effective_name = name or config.config.path.name
return json({"message": "Server name updated", "name": effective_name})
+89 -49
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import datetime import datetime
import mimetypes import mimetypes
import threading import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath from pathlib import Path, PurePath, PurePosixPath
@@ -18,15 +18,25 @@ from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
from cista import auth, config, preview, session, watching from cista import auth, config, preview, session, sso, watching
from cista.preview import shutdown_preview_workers, start_preview_workers
from cista.api import bp from cista.api import bp
from cista.sanic_logging import configure_access_logging, configure_main_logging, format_access_log
from cista.sanic_logging import logger as access_logger
from cista.util.apphelpers import handle_sanic_exception from cista.util.apphelpers import handle_sanic_exception
# Workaround until Sanic PR #2824 is merged # Workaround until Sanic PR #2824 is merged
sanic.helpers._ENTITY_HEADERS = frozenset() sanic.helpers._ENTITY_HEADERS = frozenset()
configure_access_logging()
app = Sanic("cista", strict_slashes=True) app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp) configure_main_logging()
# 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)
@@ -36,27 +46,32 @@ setproctitle("cista-main")
@app.before_server_start @app.before_server_start
async def main_start(app, loop): async def main_start(app):
config.load_config() config.load_config()
setproctitle(f"cista {config.config.path.name}") setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" max_workers=4, thread_name_prefix="cista-worker"
) )
watching.start(app, loop) # Larger pool for long-running but low-memory zip operations
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
await start_preview_workers()
watching.start(app)
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop @app.before_server_stop
async def main_stop(app, loop): async def main_stop(app):
quit.set()
watching.stop(app) watching.stop(app)
await shutdown_preview_workers()
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client()
logger.debug("Cista worker threads all finished") logger.debug("Cista worker threads all finished")
@app.on_request @app.on_request
async def use_session(req): async def use_session(req):
req.ctx._log_start = time.perf_counter()
req.ctx.session = session.get(req) req.ctx.session = session.get(req)
try: try:
req.ctx.username = req.ctx.session["username"] # type: ignore req.ctx.username = req.ctx.session["username"] # type: ignore
@@ -74,10 +89,44 @@ 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 log_access(req, res):
"""Log HTTP access in a clean single-line format."""
if req.headers.get("upgrade", "").lower() == "websocket":
return res
start = getattr(req.ctx, "_log_start", None)
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
client = req.client_ip or "-"
host = req.host or "-"
path = req.path
if req.query_string:
qs = req.query_string
if isinstance(qs, bytes):
qs = qs.decode(errors="replace")
path = f"{path}?{qs}"
extra = getattr(req.ctx, "_log_extra", None)
line = format_access_log(client, res.status, req.method, host, path, duration_ms, extra=extra)
access_logger.info(line)
return res
@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,
@@ -93,9 +142,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=10) zstd = ZstdCompressor(level=18)
while paths: while paths:
path = paths.pop(0) path = paths.pop(0)
current = base / path current = base / path
@@ -152,9 +201,8 @@ def _load_wwwroot(www):
@app.before_server_start @app.before_server_start
async def start(app): async def start(app):
await load_wwwroot(app) if not app.debug:
if app.debug: await load_wwwroot(app)
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
async def load_wwwroot(app): async def load_wwwroot(app):
@@ -164,36 +212,14 @@ async def load_wwwroot(app):
) )
quit = threading.Event()
async def refresh_wwwroot():
try:
while not quit.is_set():
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
logger.error(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
@app.route("/<path:path>", methods=["GET", "HEAD"]) @app.route("/<path:path>", methods=["GET", "HEAD"])
async def wwwroot(req, path=""): async def wwwroot(req, path=""):
"""Frontend files only""" """Frontend files only"""
if app.debug:
raise NotFound(
"Dev mode: frontend-build is not served on backend (you should connect vite)",
extra={"name": 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})
@@ -211,7 +237,7 @@ async def wwwroot(req, path=""):
@app.route("/favicon.ico", methods=["GET", "HEAD"]) @app.route("/favicon.ico", methods=["GET", "HEAD"])
async def favicon(req): async def favicon(req):
# Browsers keep asking for it when viewing files (not HTML with icon link) # Browsers keep asking for it when viewing files (not HTML with icon link)
return redirect("/assets/logo-97d1d7eb.svg", status=308) return redirect("/assets/logo-ctv8tVwU.svg", status=308)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]: def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@@ -239,6 +265,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@app.get("/zip/<keys>/<zipfile:ext=zip>") @app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext): async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys""" """Download a zip archive of the given keys"""
await auth.verify(req)
wanted = set(keys.split("+")) wanted = set(keys.split("+"))
files = get_files(wanted) files = get_files(wanted)
@@ -269,27 +296,40 @@ async def zip_download(req, keys, zipfile, ext):
yield chunk yield chunk
assert size == 0 assert size == 0
pending_put = None # Current queue.put future, can be cancelled
def worker(): def worker():
nonlocal pending_put
try: try:
for chunk in stream_zip(local_files(files)): for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result() future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
pending_put = future
future.result() # Blocks until queue has space
except asyncio.CancelledError:
logger.info("ZIP download cancelled by client disconnect")
except Exception: except Exception:
logger.exception("Error streaming ZIP") logger.exception("Error streaming ZIP")
raise raise
finally: finally:
pending_put = None
asyncio.run_coroutine_threadsafe(queue.put(None), loop) asyncio.run_coroutine_threadsafe(queue.put(None), loop)
# Don't block the event loop: run in a thread # Don't block the event loop: run in a thread (use larger zip pool)
queue = asyncio.Queue(maxsize=1) queue = asyncio.Queue(maxsize=1)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
thread = loop.run_in_executor(app.ctx.threadexec, worker) thread = loop.run_in_executor(app.ctx.zipexec, worker)
# Stream the response # Stream the response
res = await req.respond( res = await req.respond(
content_type="application/zip", content_type="application/zip",
headers={"cache-control": "no-store"}, headers={"cache-control": "no-store"},
) )
while chunk := await queue.get(): try:
await res.send(chunk) while chunk := await queue.get():
await res.send(chunk)
finally:
# Cancel any pending put to unblock and stop the worker
if pending_put:
pending_put.cancel()
await thread # If it raises, the response will fail download await thread # If it raises, the response will fail download
+263 -62
View File
@@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session from cista import config, session
from cista.util import pwgen from cista.util import pwgen
_LOGIN_PAGE_CSS = """\
/* ===========================================
LOGIN PAGE STYLES
Must match ModalDialog.vue global styles.
=========================================== */
* { box-sizing: border-box; }
body {
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
font-size: 1rem;
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.login-card {
background: #ddd;
color: #000;
border-radius: 0.5rem;
box-shadow: 0 0 1rem #0008;
width: 100%;
max-width: 320px;
}
h1 {
background: #146;
color: #fff;
margin: 0;
padding: 0.5rem 1rem;
font-size: 1.2rem;
font-weight: normal;
border-radius: 0.5rem 0.5rem 0 0;
}
.content {
padding: 1rem;
}
.message {
color: #444;
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
}
form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
}
label {
font-size: 1rem;
}
input[type="text"],
input[type="password"] {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
min-width: 0;
}
input:focus {
outline: none;
border-color: #f80;
}
.button-row {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
}
button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
background: #146;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
button:hover { background: #f80; }
button:disabled {
background: #888;
cursor: not-allowed;
}
.error {
grid-column: 1 / -1;
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
margin: 0;
}
"""
_LOGIN_PAGE_JS = """\
const form = document.getElementById('loginForm');
const error = document.getElementById('error');
const submitBtn = document.getElementById('submitBtn');
const usernameField = document.getElementById('username');
const passwordField = document.getElementById('password');
const isInIframe = window.parent !== window;
// Focus username field on load
usernameField.focus();
const showError = (msg) => {
error.textContent = msg;
submitBtn.disabled = false;
submitBtn.textContent = 'Log in';
// Focus and select the relevant field
if (msg.toLowerCase().includes('password')) {
passwordField.focus();
passwordField.select();
} else {
usernameField.focus();
usernameField.select();
}
};
form.onsubmit = async (e) => {
e.preventDefault();
error.textContent = '';
submitBtn.disabled = true;
submitBtn.textContent = 'Logging in...';
try {
const res = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
username: usernameField.value,
password: passwordField.value
})
});
if (res.ok) {
if (isInIframe) {
window.parent.postMessage({type: 'auth-success'}, '*');
} else {
window.location.href = '/';
}
} else {
const data = await res.json();
showError(data.message || data.detail || 'Login failed');
}
} catch (err) {
showError('Connection error. Please try again.');
}
};
"""
# Import for SSO validation (lazily loaded to avoid circular imports)
_sso_module = None
def _get_sso():
global _sso_module
if _sso_module is None:
from cista import sso
_sso_module = sso
return _sso_module
_argon = argon2.PasswordHasher() _argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -63,62 +231,106 @@ class LoginResponse(msgspec.Struct):
error: str = "" error: str = ""
def verify(request, *, privileged=False): async def verify(request, *, privileged=False):
"""Raise Unauthorized or Forbidden if the request is not authorized""" """Verify that the request is authorized.
if privileged:
if request.ctx.user: For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
if request.ctx.user.privileged: For built-in mode, checks session-based authentication.
return For public mode (config.public=True), skips auth unless privileged is required.
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user: Args:
request: The Sanic request object
privileged: If True, requires admin privileges (always enforced even in public mode)
Raises:
Unauthorized: If authentication is required
Forbidden: If access is denied
"""
# Public mode: skip auth unless privileged access is required
if config.config.public and not privileged:
return return
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
sso = _get_sso()
if sso.paskia_enabled():
perm = "cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm)
return
bp = Blueprint("auth") user = getattr(request.ctx, "user", None)
if privileged:
if user and user.privileged:
@bp.get("/login") return
async def login_page(request): raise Forbidden(
doc = Document("Cista Login") "Access Forbidden: Only for privileged users",
with doc.div(id="login"): quiet=True,
with doc.form(method="POST", autocomplete="on"):
doc.h1("Login")
doc.input(
name="username",
placeholder="Username",
autocomplete="username",
required=True,
).br
doc.input(
type="password",
name="password",
placeholder="Password",
autocomplete="current-password",
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",
) )
if user:
return
raise Unauthorized(
f"Login required for {request.path}",
"cookie",
context={"auth": {"iframe": "/auth/restricted/"}},
quiet=True,
)
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
bp = Blueprint("auth", url_prefix="/auth")
@bp.get("/restricted/")
async def login_page(request):
"""Login page that works both standalone and in paskia iframe."""
s = session.get(request)
# 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(
type="text",
id="username",
name="username",
autocomplete="username webauthn",
required=True,
)
doc.label("Password:", for_="password")
doc.input(
type="password",
id="password",
name="password",
autocomplete="current-password webauthn",
required=True,
)
with doc.div(class_="button-row"):
doc.button("Log in", type="submit", id="submitBtn")
doc.p("", class_="error", id="error")
# JavaScript for AJAX login and postMessage communication
doc.script_(_LOGIN_PAGE_JS)
res = html(doc) res = html(doc)
if flash:
res.cookies.delete_cookie("flash")
if s is False: if s is False:
session.delete(res) session.delete(res)
return res return res
def _login_success_page(username: str) -> str:
"""Minimal page that signals auth-success to parent iframe."""
return str(
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
)
@bp.post("/login") @bp.post("/login")
async def login_post(request): async def login_post(request):
try: try:
@@ -149,7 +361,7 @@ async def login_post(request):
return res return res
@bp.post("/logout") @bp.post("/api/logout")
async def logout_post(request): async def logout_post(request):
s = request.ctx.session s = request.ctx.session
msg = "Logged out" if s else "Not logged in" msg = "Logged out" if s else "Not logged in"
@@ -196,7 +408,7 @@ async def change_password(request):
@bp.get("/users") @bp.get("/users")
async def list_users(request): async def list_users(request):
verify(request, privileged=True) await verify(request, privileged=True)
users = [] users = []
for name, user in config.config.users.items(): for name, user in config.config.users.items():
users.append( users.append(
@@ -211,7 +423,7 @@ async def list_users(request):
@bp.post("/users") @bp.post("/users")
async def create_user(request): async def create_user(request):
verify(request, privileged=True) await verify(request, privileged=True)
try: try:
if request.headers.content_type == "application/json": if request.headers.content_type == "application/json":
username = request.json["username"] username = request.json["username"]
@@ -240,7 +452,7 @@ async def create_user(request):
@bp.put("/users/<username>") @bp.put("/users/<username>")
async def update_user(request, username): async def update_user(request, username):
verify(request, privileged=True) await verify(request, privileged=True)
try: try:
if request.headers.content_type == "application/json": if request.headers.content_type == "application/json":
changes = request.json changes = request.json
@@ -273,7 +485,7 @@ async def update_user(request, username):
@bp.delete("/users/<username>") @bp.delete("/users/<username>")
async def delete_user(request, username): async def delete_user(request, username):
verify(request, privileged=True) await verify(request, privileged=True)
if username not in config.config.users: if username not in config.config.users:
raise BadRequest("User does not exist") raise BadRequest("User does not exist")
try: try:
@@ -281,14 +493,3 @@ async def delete_user(request, username):
except Exception as e: except Exception as e:
raise BadRequest(str(e)) from e raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"}) return json({"message": f"User {username} deleted"})
@bp.put("/config/public")
async def update_public(request):
verify(request, privileged=True)
try:
public = request.json["public"]
except KeyError:
raise BadRequest("Missing public field") from None
config.update_config({"public": public})
return json({"message": "Public setting updated"})
+9 -1
View File
@@ -152,7 +152,15 @@ def modifies_config(
def load_config(): def load_config():
global config global config
init_confdir() init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) raw = conffile.read_bytes()
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
# Migrate from old authentication field if present
raw_dict = msgspec.toml.decode(raw)
if "authentication" in raw_dict and "public" not in raw_dict:
# Old config with authentication mode: migrate to public bool
new_public = raw_dict["authentication"] == "none"
config = msgspec.structs.replace(config, public=new_public)
update_config({}) # Save the migrated config
@modifies_config @modifies_config
+487 -73
View File
@@ -2,28 +2,310 @@ import asyncio
import gc import gc
import io import io
import mimetypes import mimetypes
import os
import struct
import sys
import threading
import urllib.parse import urllib.parse
from collections import OrderedDict
from dataclasses import dataclass
from multiprocessing import cpu_count
from pathlib import PurePosixPath from pathlib import PurePosixPath
from time import perf_counter 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 msgspec
import av import av
import fitz # PyMuPDF import fitz # PyMuPDF
import numpy as np import numpy as np
import pillow_heif import pillow_heif
import pyvips
from blake3 import blake3
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.preview_worker import PreviewRequest, PreviewResponse
from cista.util.filename import sanitize from cista.util.filename import sanitize
pillow_heif.register_heif_opener() pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
@dataclass(slots=True)
class CachedPreview:
"""Cached preview with headers and body."""
headers: dict[str, str]
body: bytes
class PreviewCache:
"""Thread-safe LRU cache for preview responses."""
def __init__(self, capacity: int = 500):
self.capacity = capacity
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
self._lock = threading.Lock()
def get(self, key: str) -> CachedPreview | None:
"""Get cached preview, moving it to end (most recently used)."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
return self._cache[key]
return None
def set(self, key: str, value: CachedPreview) -> None:
"""Cache preview, evicting oldest if at capacity."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
else:
if len(self._cache) >= self.capacity:
self._cache.popitem(last=False)
self._cache[key] = value
def __len__(self) -> int:
with self._lock:
return len(self._cache)
# Global preview cache instance
_preview_cache = PreviewCache(capacity=500)
PREVIEW_TIMEOUT = 3.0 # seconds until preview subprocess is killed
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
_active_procs: set[asyncio.subprocess.Process] = set()
_preview_pool = None
_preview_pool_lock = asyncio.Lock()
AVIF_FAST_EFFORT = 0
FORCE_PIL = os.environ.get("CISTA_PIL") == "1"
WORKER_CHECKSUM_BYTES = 32
WORKER_MAX_JSON_BYTES = 1_000_000
class WorkerChecksumError(Exception):
"""Raised when worker response checksum does not match the packet."""
class WorkerProtocolError(Exception):
"""Raised when worker response packet is malformed."""
class _PreviewWorker:
def __init__(self, proc: asyncio.subprocess.Process):
self.proc = proc
async def request(self, filepath, quality: int, maxsize: int, maxzoom: float):
if self.proc.returncode is not None:
raise WorkerProtocolError("worker already exited")
if self.proc.stdin is None or self.proc.stdout is None:
raise WorkerProtocolError("worker streams not available")
line = (
msgspec.json.encode(
PreviewRequest(
path=str(filepath),
quality=quality,
maxsize=maxsize,
maxzoom=maxzoom,
)
)
+ b"\n"
)
self.proc.stdin.write(line)
await self.proc.stdin.drain()
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
header = await self.proc.stdout.readexactly(8)
json_size, data_size = struct.unpack("<II", header)
if json_size > WORKER_MAX_JSON_BYTES:
raise WorkerProtocolError(f"worker JSON too large: {json_size}")
meta_raw = await self.proc.stdout.readexactly(json_size)
payload = await self.proc.stdout.readexactly(data_size)
packet = header + meta_raw + payload
if blake3(packet).digest() != checksum:
raise WorkerChecksumError("worker checksum mismatch")
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
if not resp.ok:
raise PreviewError(resp.error or "preview worker error")
return payload or None, resp
async def kill(self) -> None:
if self.proc.returncode is None:
try:
self.proc.kill()
except ProcessLookupError:
pass
await self.proc.wait()
_active_procs.discard(self.proc)
class _PreviewWorkerPool:
def __init__(self, size: int):
self.size = size
self._idle: asyncio.Queue[_PreviewWorker] = asyncio.Queue()
self._workers: set[_PreviewWorker] = set()
self._closed = False
async def _spawn_worker(self) -> _PreviewWorker:
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"cista.preview_worker",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
start_new_session=True,
)
_active_procs.add(proc)
return _PreviewWorker(proc)
async def _add_worker(self) -> None:
worker = await self._spawn_worker()
self._workers.add(worker)
await self._idle.put(worker)
async def _replace_worker(self, worker: _PreviewWorker) -> None:
self._workers.discard(worker)
await worker.kill()
if self._closed:
return
try:
await self._add_worker()
except Exception:
logger.exception("Failed to replace preview worker")
async def start(self) -> None:
for _ in range(self.size):
await self._add_worker()
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
if self._closed:
raise PreviewError("preview worker pool closed")
worker = await self._idle.get()
replace = False
try:
out, resp = await asyncio.wait_for(
worker.request(filepath, quality, maxsize, maxzoom),
timeout=PREVIEW_TIMEOUT,
)
return out, resp
except asyncio.TimeoutError:
replace = True
logger.warning(
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
)
raise PreviewTimeout(filepath.name)
except WorkerChecksumError:
replace = True
logger.error("Preview checksum mismatch for %s", filepath.name)
raise PreviewError(filepath.name)
except PreviewError:
raise
except (
WorkerProtocolError,
asyncio.IncompleteReadError,
BrokenPipeError,
ConnectionResetError,
OSError,
ValueError,
msgspec.json.DecodeError,
) as e:
replace = True
logger.warning(
"Preview worker protocol failure for %s: %s", filepath.name, e
)
raise PreviewError(filepath.name)
finally:
if replace:
await self._replace_worker(worker)
else:
if worker.proc.returncode is None:
await self._idle.put(worker)
else:
await self._replace_worker(worker)
async def close(self) -> None:
self._closed = True
workers = list(self._workers)
self._workers.clear()
while not self._idle.empty():
try:
self._idle.get_nowait()
except asyncio.QueueEmpty:
break
await asyncio.gather(
*(worker.kill() for worker in workers), return_exceptions=True
)
async def start_preview_workers() -> None:
"""Warm up persistent preview workers during server startup."""
global _preview_pool
if _preview_pool is not None:
return
async with _preview_pool_lock:
if _preview_pool is not None:
return
pool = _PreviewWorkerPool(PREVIEW_WORKERS)
await pool.start()
_preview_pool = pool
logger.info("Started %d persistent preview workers", PREVIEW_WORKERS)
async def shutdown_preview_workers() -> None:
"""Kill persistent preview workers (called during server shutdown)."""
global _preview_pool
async with _preview_pool_lock:
pool = _preview_pool
_preview_pool = None
if pool is not None:
await pool.close()
if not _active_procs:
return
for proc in list(_active_procs):
try:
proc.kill()
except ProcessLookupError:
pass
await asyncio.gather(
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
)
_active_procs.clear()
@bp.on_request
async def verify_preview(request):
"""Verify access to preview routes."""
await auth.verify(request)
class PreviewTimeout(Exception):
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
class PreviewError(Exception):
"""Raised when the preview subprocess exits with a non-zero status."""
async def _run_preview_process(
filepath, quality: int, maxsize: int, maxzoom: float
) -> tuple[bytes | None, PreviewResponse | None]:
"""Run preview request in a persistent worker process."""
await start_preview_workers()
if _preview_pool is None:
raise PreviewError(filepath.name)
return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
# Map EXIF Orientation value to a corresponding PIL transpose # Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = { EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT, 2: Image.Transpose.FLIP_LEFT_RIGHT,
@@ -36,6 +318,19 @@ EXIF_ORI = {
} }
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
def is_previewable_path(path) -> bool:
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
return True
mime_type, _ = mimetypes.guess_type(path.name)
if not mime_type:
return False
return mime_type.startswith("image/") or mime_type.startswith("video/")
@bp.get("/<path:path>") @bp.get("/<path:path>")
async def preview(req, path): async def preview(req, path):
"""Preview a file""" """Preview a file"""
@@ -43,43 +338,120 @@ async def preview(req, path):
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60)) quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path))) rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel filepath = config.config.path / rel
stat = path.lstat() try:
stat = filepath.lstat()
except FileNotFoundError:
raise NotFound() from None
if not is_previewable_path(filepath):
return empty(415)
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).hex()
savename = PurePosixPath(path.name).with_suffix(".avif")
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers={"etag": etag})
# Check in-memory cache first (includes headers)
cached = _preview_cache.get(etag)
if cached is not None:
logger.debug(f"Preview cache hit: {rel}")
return raw(cached.body, headers=cached.headers)
# Generate preview
try:
img, preview_resp = await _run_preview_process(
filepath, quality, maxsize, maxzoom
)
except PreviewTimeout:
return empty(504)
except PreviewError:
return empty(422)
if preview_resp and preview_resp.backend:
if preview_resp.timings:
timing_detail = "/".join(
str(int(round(value))) for value in preview_resp.timings
)
req.ctx._log_extra = f"{preview_resp.backend} {timing_detail}"
else:
req.ctx._log_extra = preview_resp.backend
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
# Build headers and cache the full response
preview_mime = (
preview_resp.mime
if preview_resp is not None and preview_resp.mime is not None
else "image/avif"
)
savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
"last-modified": format_date_time(stat.st_mtime), "last-modified": format_date_time(stat.st_mtime),
"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/avif", "content-type": preview_mime,
"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: _preview_cache.set(etag, CachedPreview(headers=headers, body=img))
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
if not path.is_file():
raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
)
return raw(img, headers=headers) return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"): try:
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) if path.suffix.lower() in DOC_PREVIEW_SUFFIXES:
type, _ = mimetypes.guess_type(path.name) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if type and type.startswith("video/"): mime_type, _ = mimetypes.guess_type(path.name)
return process_video(path, quality=quality, maxsize=maxsize) if mime_type and mime_type.startswith("video/"):
return process_image(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
if mime_type and mime_type.startswith("image/"):
return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e:
logger.warning(f"Cannot generate preview for {path}: {e}")
except Exception as e:
logger.exception(f"Error generating preview for {path}: {e}")
return None, PreviewResponse(ok=False)
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
return process_image_with_timing(path, maxsize=maxsize, quality=quality)
def process_image_with_timing(path, *, maxsize, quality):
if FORCE_PIL:
return process_image_pillow(path, maxsize=maxsize, quality=quality)
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
def process_image_pyvips(path, *, maxsize, quality):
t_start = perf_counter()
img = pyvips.Image.new_from_file(str(path), access="sequential")
img = img.autorot()
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
ret = img.write_to_buffer(
".avif",
Q=quality,
effort=AVIF_FAST_EFFORT,
strip=True,
)
t_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend="pyvips",
timings=[round((t_end - t_start) * 1000, 1)],
)
def process_image_pillow(path, *, maxsize, quality):
t_load = perf_counter() t_load = perf_counter()
with Image.open(path) as img: with Image.open(path) as img:
# Force decode to include I/O in load timing # Force decode to include I/O in load timing
@@ -95,7 +467,14 @@ def process_image(path, *, maxsize, quality):
# Save as AVIF # Save as AVIF
imgdata = io.BytesIO() imgdata = io.BytesIO()
t_save = perf_counter() t_save = perf_counter()
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1) img.save(
imgdata,
format="avif",
quality=quality,
speed=10,
max_threads=1,
avif=1,
)
t_end = perf_counter() t_end = perf_counter()
ret = imgdata.getvalue() ret = imgdata.getvalue()
@@ -103,16 +482,13 @@ def process_image(path, *, maxsize, quality):
load_ms = (t_proc - t_load) * 1000 load_ms = (t_proc - t_load) * 1000
proc_ms = (t_save - t_proc) * 1000 proc_ms = (t_save - t_proc) * 1000
save_ms = (t_end - t_save) * 1000 save_ms = (t_end - t_save) * 1000
logger.debug( return ret, PreviewResponse(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms", ok=True,
path.name, mime="image/avif",
load_ms, backend="pillow",
proc_ms, timings=[round(load_ms, 1), round(proc_ms, 1), round(save_ms, 1)],
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() t_load_start = perf_counter()
@@ -121,20 +497,34 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
w, h = page.rect[2:4] w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom) zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom) mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined] pix = page.get_pixmap(matrix=mat)
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1) if FORCE_PIL:
ret = pix.pil_tobytes(
format="avif", quality=quality, speed=10, max_threads=1, avif=1
)
backend = "pdf"
else:
img = pyvips.Image.new_from_memory(
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
)
ret = img.write_to_buffer(
".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True
)
backend = "pdf+pyvips"
t_save_end = perf_counter() t_save_end = perf_counter()
logger.debug( return ret, PreviewResponse(
"Preview pdf %s: load+render=%.1fms save=%.1fms", ok=True,
path.name, mime="image/avif",
(t_load_end - t_load_start) * 1000, backend=backend,
(t_save_end - t_save_start) * 1000, timings=[
round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1),
],
) )
return ret
def process_video(path, *, maxsize, quality): def process_video(path, *, maxsize, quality):
@@ -147,7 +537,13 @@ def process_video(path, *, maxsize, quality):
t_save_start = t_load_start t_save_start = t_load_start
t_save_end = t_load_start t_save_end = t_load_start
with ( with (
av.open(str(path)) as icontainer, av.open(
str(path),
options={
"analyzeduration": "1000000", # 1 second (in microseconds)
"fflags": "fastseek",
},
) as icontainer,
av.open(imgdata, "w", format="avif") as ocontainer, av.open(imgdata, "w", format="avif") as ocontainer,
): ):
istream = icontainer.streams.video[0] istream = icontainer.streams.video[0]
@@ -166,35 +562,49 @@ def process_video(path, *, maxsize, quality):
new_height = int(frame.height * scale_factor) new_height = int(frame.height * scale_factor)
frame = frame.reformat(width=new_width, height=new_height) frame = frame.reformat(width=new_width, height=new_height)
# Simple rotation detection and logging # Apply EXIF rotation if present
if frame.rotation: if frame.rotation:
try: # frame.rotation indicates clockwise rotation needed to display correctly
fplanes = frame.to_ndarray() # np.rot90 rotates counter-clockwise, so we negate k
# Split into Y, U, V planes of proper dimensions k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
planes = [ if k == 2:
fplanes[: frame.height], # 180° rotation can be done in YUV420p, preserving HDR
fplanes[frame.height : frame.height + frame.height // 4].reshape( try:
frame.height // 2, frame.width // 2 fplanes = frame.to_ndarray()
), # Split into Y, U, V planes of proper dimensions
fplanes[frame.height + frame.height // 4 :].reshape( planes = [
frame.height // 2, frame.width // 2 fplanes[: frame.height],
), fplanes[
] frame.height : frame.height + frame.height // 4
# Rotate ].reshape(frame.height // 2, frame.width // 2),
planes = [np.rot90(p, frame.rotation // 90) for p in planes] fplanes[frame.height + frame.height // 4 :].reshape(
# Restore PyAV format frame.height // 2, frame.width // 2
planes = np.hstack([p.flat for p in planes]).reshape( ),
-1, planes[0].shape[1] ]
) # Rotate each plane by 180°
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name) planes = [np.rot90(p, 2) for p in planes]
del planes, fplanes # Restore PyAV format
except Exception as e: planes = np.hstack([p.flat for p in planes]).reshape(
if "not yet supported" in str(e): -1, planes[0].shape[1]
logger.warning( )
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}" frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
del planes, fplanes
except Exception as e:
logger.exception(f"Error rotating video frame by 180°: {e}")
elif k in (1, 3):
# 90° or 270° rotation requires RGB conversion (loses HDR)
try:
rgb = frame.to_ndarray(format="rgb24")
rgb = np.rot90(rgb, k)
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
frame = frame.reformat(
format="yuv420p"
) # Convert back for encoding
del rgb
except Exception as e:
logger.exception(
f"Error rotating video frame by {frame.rotation}°: {e}"
) )
else:
logger.exception(f"Error rotating video frame: {e}")
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
@@ -211,6 +621,7 @@ def process_video(path, *, maxsize, quality):
assert isinstance(ostream, av.VideoStream) assert isinstance(ostream, av.VideoStream)
ostream.width = frame.width ostream.width = frame.width
ostream.height = frame.height ostream.height = frame.height
ostream.pix_fmt = frame.format.name
icc = istream.codec_context icc = istream.codec_context
occ = ostream.codec_context occ = ostream.codec_context
@@ -224,14 +635,17 @@ def process_video(path, *, maxsize, quality):
ocontainer.mux(ostream.encode(None)) # Flush the stream ocontainer.mux(ostream.encode(None)) # Flush the stream
t_save_end = perf_counter() t_save_end = perf_counter()
# Capture frame dimensions before cleanup # Capture result before cleanup
ret = imgdata.getvalue() ret = imgdata.getvalue()
logger.debug( resp = PreviewResponse(
"Preview video %s: load+decode=%.1fms save=%.1fms", ok=True,
path.name, mime="image/avif",
(t_load_end - t_load_start) * 1000, backend="video",
(t_save_end - t_save_start) * 1000, timings=[
round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1),
],
) )
del imgdata, istream, ostream, icc, occ, frame del imgdata, istream, ostream, icc, occ, frame
gc.collect() gc.collect()
return ret return ret, resp
+93
View File
@@ -0,0 +1,93 @@
"""Preview generation worker subprocess.
Two modes are supported:
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
2) Long-lived mode: read JSONL commands from stdin and write framed responses.
Framed response format:
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
"""
import logging
import struct
import sys
from pathlib import Path
import msgspec
from blake3 import blake3
class PreviewRequest(msgspec.Struct, omit_defaults=True):
path: str
quality: int
maxsize: int
maxzoom: float
class PreviewResponse(msgspec.Struct, omit_defaults=True):
ok: bool
mime: str | None = None
backend: str | None = None
timings: list[float] | None = None
error: str | None = None
_enc = msgspec.json.Encoder()
_dec_req = msgspec.json.Decoder(PreviewRequest)
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
meta_bytes = _enc.encode(resp)
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
checksum = blake3(packet).digest()
sys.stdout.buffer.write(checksum)
sys.stdout.buffer.write(packet)
sys.stdout.buffer.flush()
def _run_once() -> None:
if len(sys.argv) != 5:
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
sys.exit(1)
from cista.preview import dispatch
path = Path(sys.argv[1])
quality = int(sys.argv[2])
maxsize = int(sys.argv[3])
maxzoom = float(sys.argv[4])
result, _ = dispatch(path, quality, maxsize, maxzoom)
if result:
sys.stdout.buffer.write(result)
sys.stdout.buffer.flush()
def _run_loop() -> None:
from cista.preview import dispatch
while True:
line = sys.stdin.buffer.readline()
if not line:
return
try:
req = _dec_req.decode(line)
result, resp = dispatch(
Path(req.path), req.quality, req.maxsize, req.maxzoom
)
_write_response(resp, result or b"")
except Exception as e:
_write_response(PreviewResponse(ok=False, error=str(e)), b"")
def main() -> None:
# Configure all log output to stderr before any imports that may emit logs.
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
if len(sys.argv) > 1:
_run_once()
return
_run_loop()
if __name__ == "__main__":
main()
+31 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
from pathlib import PurePosixPath
from typing import Any from typing import Any
import msgspec import msgspec
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self): def __call__(self):
raise NotImplementedError raise NotImplementedError
def affected_paths(self) -> list[str]:
"""Return list of paths affected by this operation for change notification."""
return []
class MkDir(ControlBase): class MkDir(ControlBase):
path: str path: str
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
path = config.config.path / filename.sanitize(self.path) path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=True, exist_ok=False) path.mkdir(parents=True, exist_ok=False)
def affected_paths(self) -> list[str]:
return [filename.sanitize(self.path)]
class Rename(ControlBase): class Rename(ControlBase):
path: str path: str
@@ -36,6 +44,11 @@ class Rename(ControlBase):
path = config.config.path / filename.sanitize(self.path) path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to)) path.rename(path.with_name(to))
def affected_paths(self) -> list[str]:
sanitized = filename.sanitize(self.path)
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
return [sanitized, new_path]
class Rm(ControlBase): class Rm(ControlBase):
sel: list[str] sel: list[str]
@@ -49,6 +62,9 @@ class Rm(ControlBase):
else: else:
p.unlink() p.unlink()
def affected_paths(self) -> list[str]:
return [filename.sanitize(p) for p in self.sel]
class Mv(ControlBase): class Mv(ControlBase):
sel: list[str] sel: list[str]
@@ -63,6 +79,13 @@ class Mv(ControlBase):
for p in sel: for p in sel:
shutil.move(p, dst) shutil.move(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
paths = [filename.sanitize(p) for p in self.sel]
# Include new locations in dst
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
return paths
class Cp(ControlBase): class Cp(ControlBase):
sel: list[str] sel: list[str]
@@ -86,6 +109,11 @@ class Cp(ControlBase):
else: else:
shutil.copy2(p, dst) shutil.copy2(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
# Only destinations are new (sources unchanged)
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
ControlTypes = MkDir | Rename | Rm | Mv | Cp ControlTypes = MkDir | Rename | Rm | Mv | Cp
@@ -118,6 +146,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
key: str key: str
mtime: int mtime: int
size: int size: int
allocated: int
isfile: int isfile: int
def __str__(self): def __str__(self):
@@ -149,5 +178,6 @@ class UpdateMessage(msgspec.Struct):
class Space(msgspec.Struct): class Space(msgspec.Struct):
disk: int disk: int
free: int free: int
usage: int used: int
storage: int storage: int
allocated: int
+256
View File
@@ -0,0 +1,256 @@
"""Custom access logging middleware for Sanic."""
import logging
import sys
import unicodedata
from ipaddress import IPv6Address
logger = logging.getLogger("cista.access")
_RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (light grey)
_TIMING = "\033[38;5;242m" # timing (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
def format_ipv6_network(ip: str) -> str:
"""Format IPv6 address to show only network part (first 64 bits)."""
try:
ip = ip.strip("[]")
if "%" in ip:
ip = ip.split("%")[0]
addr = IPv6Address(ip)
if addr.is_loopback:
return "::1"
if addr.is_unspecified:
return "::"
if addr.ipv4_mapped:
return str(addr.ipv4_mapped)
if addr.is_link_local:
return str(addr)
network_int = int(addr) >> 64
groups = []
for _ in range(4):
groups.insert(0, format(network_int & 0xFFFF, "x"))
network_int >>= 16
result = ":".join(groups) + "::"
return str(IPv6Address(result + "0")).removesuffix("::")
except Exception:
return ip
def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
stripped = ip.strip("[]")
if ":" in stripped:
return format_ipv6_network(stripped)
return stripped
def status_color(status: int) -> str:
if status < 200:
return _STATUS_INFO
if status < 300:
return _STATUS_OK
if status < 400:
return _STATUS_REDIRECT
if status < 500:
return _STATUS_CLIENT_ERR
return _STATUS_SERVER_ERR
def method_color(method: str) -> str:
if method in ("GET", "HEAD", "OPTIONS"):
return _METHOD_READ
return _METHOD_WRITE
def format_duration_ms(duration_ms: float) -> str:
rounded_ms = round(duration_ms)
if rounded_ms < 2000:
return f"{rounded_ms}ms"
total_s = round(duration_ms / 1000)
if total_s < 60:
return f"{total_s}s"
if total_s <= 3600:
minutes, seconds = divmod(total_s, 60)
return f"{minutes}m{seconds}s"
hours, remainder = divmod(total_s, 3600)
minutes = round(remainder / 60)
if minutes == 60:
hours += 1
minutes = 0
return f"{hours}h{minutes}m"
def _display_width(text: str) -> int:
width = 0
for char in text:
width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
return width
def _format_left(label: str) -> str:
return label[:19].ljust(19)
def _format_method_label(label: str, *, color: str | None = None) -> str:
color_value = _METHOD_WRITE if color is None else color
padding = max(0, 7 - _display_width(label))
return f"{color_value}{label}{' ' * padding}{_RESET}"
def format_access_log(
client: str, status: int, method: str, host: str, path: str, duration_ms: float,
extra: str | None = None,
) -> str:
ip = _format_left(format_client_ip(client))
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
method_str = _format_method_label(method, color=method_color(method))
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
_ws_counter = 1
def _next_ws_id() -> int:
global _ws_counter
ws_id = _ws_counter
_ws_counter += 1
return ws_id
def _format_ws_id(ws_id: int, *, bright: bool = False) -> str:
value = str(ws_id) if ws_id >= 100 else f"{ws_id:02d}"
color = _WS_OPEN if bright else _WS_CLOSE
return f"{color}{value.rjust(3)}{_RESET}"
def log_ws_open(request, extra: str | None = None) -> int:
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
ws_id = _next_ws_id()
client = request.client_ip or "-"
host = request.host or "-"
path = request.path
origin = request.headers.get("origin")
ip = _format_left(format_client_ip(client))
id_str = _format_ws_id(ws_id, bright=True)
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
method_str = _format_method_label("🔌", color=_WS_OPEN)
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info(
"%s %s %s %s%s%s",
ip,
id_str,
method_str,
host_str,
path_str,
origin_str + extra_str,
)
return ws_id
WS_CLOSE_CODES = {
1000: "ok",
1001: "going away",
1002: "protocol error",
1003: "unsupported",
1005: "no status",
1006: "abnormal",
1007: "invalid data",
1008: "policy violation",
1009: "too large",
1010: "extension required",
1011: "server error",
1012: "restarting",
1013: "try again",
1014: "bad gateway",
1015: "tls error",
}
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status."""
id_str = _format_ws_id(ws_id)
timing = format_duration_ms(duration * 1000)
if close_code is None:
code = "----"
status = "unknown"
else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
method_str = _format_method_label("closed", color=_TIMING)
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}"
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
def configure_access_logging() -> None:
"""Configure the cista.access logger to output to stderr."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_LEVEL_EMOJI = {
logging.DEBUG: "🔍",
logging.INFO: "",
logging.WARNING: "⚠️",
logging.ERROR: "🛑",
logging.CRITICAL: "🛑",
}
class _EmojiFormatter(logging.Formatter):
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
def format(self, record: logging.LogRecord) -> str:
emoji = _LEVEL_EMOJI.get(record.levelno, "▪️")
return f"{emoji} {record.getMessage()}"
def configure_main_logging() -> None:
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
call Sanic makes during serve_single() / serve().
"""
from sanic.log import LOGGING_CONFIG_DEFAULTS
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
"class": "cista.sanic_logging._EmojiFormatter",
}
# Also reformat any handlers already attached (covers the initial Sanic() call)
for name in ("sanic.root", "sanic.error", "sanic.server", "sanic.websockets"):
for handler in logging.getLogger(name).handlers:
handler.setFormatter(_EmojiFormatter())
+17 -11
View File
@@ -2,6 +2,7 @@ import os
import re import re
from pathlib import Path from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic from sanic import Sanic
from cista import config, server80 from cista import config, server80
@@ -26,7 +27,7 @@ def run(*, dev=False):
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
access_log=True, access_log=False,
) # type: ignore ) # type: ignore
if dev: if dev:
Sanic.serve() Sanic.serve()
@@ -44,18 +45,23 @@ def check_cert(certdir, domain):
def parse_listen(listen): def parse_listen(listen):
if listen.startswith("/"): # Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
unix = Path(listen).resolve() if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
# Use fastapi_vue's parse_endpoint for everything else
endpoints = parse_endpoint(listen, default_port=8989)
ep = endpoints[0]
if "uds" in ep:
unix = Path(ep["uds"]).resolve()
if not unix.parent.exists(): if not unix.parent.exists():
raise ValueError( raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/", f"Directory for unix socket does not exist: {unix.parent}/",
) )
return "http://localhost", {"unix": unix.as_posix()} return "http://localhost", {"unix": unix.as_posix()}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True} host, port = ep["host"], ep["port"]
try: if len(endpoints) > 1:
addr, _port = listen.split(":", 1) return f"http://localhost:{port}", {"host": host, "port": port}
port = int(_port) return f"http://{host}:{port}", {"host": host, "port": port}
except Exception:
raise ValueError(f"Invalid listen address: {listen}") from None
return f"http://localhost:{port}", {"host": addr, "port": port}
+316
View File
@@ -0,0 +1,316 @@
"""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, json
from sanic import raw as raw_response
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)
if "user-agent" in _client.headers:
del _client.headers["user-agent"] # No httpx UA
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",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"forwarded",
}
headers = [
(key, value)
for key, value in request.headers.items()
if key.lower() not in skip_headers
]
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
headers.append(("x-forwarded-host", request.host))
headers.append(("x-forwarded-proto", request.scheme))
headers.append(
(
"forwarded",
f"by=cista;for={request.client_ip};host={request.host};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
]
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}")
return json(
{"detail": "Authentication service unavailable"},
status=503,
)
async def proxy_auth_websocket(request, ws):
"""Proxy a WebSocket connection to the auth backend."""
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
if request.query_string:
url = f"{url}?{request.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 "host" in request.headers:
additional_headers["host"] = request.headers["host"]
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.client_ip.strip("[]")
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}")
# Blueprint for auth proxy routes (only registered when paskia_enabled())
bp = Blueprint("sso", url_prefix="/auth")
@bp.websocket("/ws/<path:path>")
async def auth_websocket_proxy(request, ws, path=""):
"""Proxy WebSocket connections to the auth backend."""
await proxy_auth_websocket(request, ws)
@bp.websocket("/ws/")
async def auth_websocket_proxy_root(request, ws):
"""Proxy root WebSocket connections to the auth backend."""
await proxy_auth_websocket(request, ws)
@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."""
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."""
return await proxy_auth_request(request)
+25 -2
View File
@@ -1,3 +1,4 @@
import time
from functools import wraps from functools import wraps
import msgspec import msgspec
@@ -8,6 +9,7 @@ from sanic.response import raw, redirect
from cista import auth from cista import auth
from cista.protocol import ErrorMsg from cista.protocol import ErrorMsg
from cista.sanic_logging import log_ws_close, log_ws_open
def asend(ws, msg): def asend(ws, msg):
@@ -33,8 +35,11 @@ async def handle_sanic_exception(request, e):
logger.exception(e) logger.exception(e)
# Non-browsers get JSON errors # Non-browsers get JSON errors
if "text/html" not in request.headers.accept: if "text/html" not in request.headers.accept:
# Include auth context if present (for SSO auth required responses)
# Auth must be at top level for paskia library to detect it
response_data = {"code": code, "message": message, "detail": message, **context}
return jres( return jres(
ErrorMsg({"code": code, "message": message, **context}), response_data,
status=code, status=code,
) )
# Redirections flash the error message via cookies # Redirections flash the error message via cookies
@@ -51,8 +56,12 @@ def websocket_wrapper(handler):
@wraps(handler) @wraps(handler)
async def wrapper(request, ws, *args, **kwargs): async def wrapper(request, ws, *args, **kwargs):
username = getattr(request.ctx, "username", None)
extra = username if username else None
start = time.perf_counter()
ws_id = log_ws_open(request, extra=extra)
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"
@@ -64,5 +73,19 @@ def websocket_wrapper(handler):
if not getattr(e, "quiet", False) or code == 500: if not getattr(e, "quiet", False) or code == 500:
logger.exception(f"{code} {e!r}") logger.exception(f"{code} {e!r}")
raise raise
finally:
duration = time.perf_counter() - start
close_code = None
try:
p = ws.ws_proto
if p.close_rcvd is not None:
close_code = p.close_rcvd.code
elif p.close_sent is not None:
close_code = p.close_sent.code
elif getattr(p, "close_code", None) is not None:
close_code = p.close_code
except AttributeError:
pass
log_ws_close(ws_id, close_code, duration)
return wrapper return wrapper
+457 -89
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import queue
import shutil import shutil
import sys import sys
import threading import threading
@@ -16,6 +17,33 @@ from cista import config
from cista.fileio import fuid from cista.fileio import fuid
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
# Platform-specific allocated size calculation
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
kernel32 = ctypes.windll.kernel32
GetCompressedFileSizeW = kernel32.GetCompressedFileSizeW
GetCompressedFileSizeW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
GetCompressedFileSizeW.restype = wintypes.DWORD
INVALID_FILE_SIZE = 0xFFFFFFFF
def get_allocated_size(path: Path, st: stat_result) -> int:
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
high = wintypes.DWORD()
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
raise OSError(f"GetCompressedFileSizeW failed for {path}")
return (high.value << 32) + low
else:
def get_allocated_size(path: Path, st: stat_result) -> int:
"""Get actual disk allocation on Unix using st_blocks."""
# st_blocks is in 512-byte units
return st.st_blocks * 512
pubsub = {} pubsub = {}
sortkey = natsort_keygen(alg=ns.LOCALE) sortkey = natsort_keygen(alg=ns.LOCALE)
@@ -23,7 +51,7 @@ sortkey = natsort_keygen(alg=ns.LOCALE)
class State: class State:
def __init__(self): def __init__(self):
self.lock = threading.RLock() self.lock = threading.RLock()
self._space = Space(0, 0, 0, 0) self._space = Space(0, 0, 0, 0, 0)
self.root: list[FileEntry] = [] self.root: list[FileEntry] = []
@property @property
@@ -116,6 +144,28 @@ state = State()
rootpath: Path = None # type: ignore rootpath: Path = None # type: ignore
quit = threading.Event() quit = threading.Event()
# Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets."""
for path in paths:
if isinstance(path, str):
path = PurePosixPath(path)
# Convert absolute paths to relative (strip leading /)
if path.is_absolute():
path = (
PurePosixPath(*path.parts[1:])
if len(path.parts) > 1
else PurePosixPath()
)
# Skip root paths (empty, '.') to avoid full tree walks
if not path.parts or path.parts == (".",):
continue
_update_queue.put(path)
## Filesystem scanning ## Filesystem scanning
@@ -125,12 +175,18 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
try: try:
st = stat or path.stat() st = stat or path.stat()
isfile = int(not S_ISDIR(st.st_mode)) isfile = int(not S_ISDIR(st.st_mode))
try:
allocated = get_allocated_size(path, st) if isfile else 0
except Exception:
logger.exception(f"get_allocated_size failed for {path}")
allocated = st.st_size if isfile else 0
entry = FileEntry( entry = FileEntry(
level=len(rel.parts), level=len(rel.parts),
name=rel.name, name=rel.name,
key=fuid(st), key=fuid(st),
mtime=int(st.st_mtime), mtime=int(st.st_mtime),
size=st.st_size if isfile else 0, size=st.st_size if isfile else 0,
allocated=allocated,
isfile=isfile, isfile=isfile,
) )
if isfile: if isfile:
@@ -158,8 +214,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
level=entry.level, level=entry.level,
name=entry.name, name=entry.name,
key=entry.key, key=entry.key,
size=entry.size + child.size,
mtime=max(entry.mtime, child.mtime), mtime=max(entry.mtime, child.mtime),
size=entry.size + child.size,
allocated=entry.allocated + child.allocated,
isfile=entry.isfile, isfile=entry.isfile,
) )
ret.extend(sub) ret.extend(sub)
@@ -204,7 +261,14 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
def update_space(loop): def update_space(loop):
"""Called periodically to update the disk usage.""" """Called periodically to update the disk usage."""
du = shutil.disk_usage(rootpath) du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size) root = state.root[0]
space = Space(
disk=du.total,
free=du.free,
used=du.used,
storage=root.size,
allocated=root.allocated,
)
# Update only on difference above 1 MB # Update only on difference above 1 MB
tol = 10**6 tol = 10**6
old = msgspec.structs.astuple(state.space) old = msgspec.structs.astuple(state.space)
@@ -326,128 +390,432 @@ def format_root(root):
def broadcast(msg, loop): def broadcast(msg, loop):
return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result() fut = asyncio.run_coroutine_threadsafe(abroadcast(msg), loop)
return fut.result()
async def abroadcast(msg): async def abroadcast(msg):
client_count = 0
try: try:
for queue in pubsub.values(): for queue in pubsub.values():
queue.put_nowait(msg) queue.put_nowait(msg)
client_count += 1
except Exception: except Exception:
# Log because asyncio would silently eat the error # Log because asyncio would silently eat the error
logger.exception("Broadcast error") logger.exception("Broadcast error")
return client_count
## Watcher thread ## Watcher thread
def watcher_inotify(loop): class PathIndex:
"""Inotify watcher thread (Linux only)""" """O(1) path lookup index for the flat FileEntry tree."""
import inotify.adapters
def __init__(self, root: list[FileEntry]):
self.root = root
self._index: dict[PurePosixPath, tuple[int, int]] = {}
self._rebuild()
def _rebuild(self):
"""Build path -> (start_idx, count) mapping in single O(n) pass."""
index: dict[PurePosixPath, tuple[int, int]] = {}
path_stack: list[tuple[PurePosixPath, int]] = [] # (path, start_idx)
for i, entry in enumerate(self.root):
# Pop completed paths from stack
while path_stack and entry.level <= len(path_stack[-1][0].parts):
completed_path, start_idx = path_stack.pop()
index[completed_path] = (start_idx, i - start_idx)
# Build current path
if entry.level == 0:
current_path = PurePosixPath()
else:
parent = path_stack[-1][0] if path_stack else PurePosixPath()
current_path = parent / entry.name
path_stack.append((current_path, i))
# Close remaining paths
for path, start_idx in path_stack:
index[path] = (start_idx, len(self.root) - start_idx)
self._index = index
def get(self, path: PurePosixPath) -> tuple[int | None, list[FileEntry]]:
"""O(1) lookup: returns (start_idx, entries) or (None, [])."""
if path not in self._index:
return None, []
start, count = self._index[path]
return start, self.root[start : start + count]
def find_insert_pos(self, path: PurePosixPath, isfile: int) -> int:
"""Find insertion position using index + binary search."""
if not path.parts:
return 0
parent = path.parent
name = path.name
# Find parent's range
if parent == PurePosixPath():
# Insert at root level - scan root's direct children
start, count = 0, len(self.root)
target_level = 1
elif parent in self._index:
start, count = self._index[parent]
start += 1 # Skip parent entry itself
count -= 1
target_level = len(parent.parts) + 1
else:
# Parent doesn't exist, shouldn't happen
return len(self.root)
# Binary search among direct children at target_level
# Collect children indices first
children = []
i = start
end = start + count
while i < end:
entry = self.root[i]
if entry.level == target_level:
children.append(i)
i += 1
if not children:
return start
# Binary search for insertion point
nsort = sortkey(name)
lo, hi = 0, len(children)
while lo < hi:
mid = (lo + hi) // 2
idx = children[mid]
entry = self.root[idx]
ename = entry.name
esort = sortkey(ename)
# Compare: isfile, then sort key, then case-sensitive
cmp = (
entry.isfile - isfile
or (esort > nsort) - (esort < nsort)
or (ename > name) - (ename < name)
)
if cmp < 0:
lo = mid + 1
else:
hi = mid
if lo < len(children):
return children[lo]
elif children:
# Insert after last child's subtree
last_idx = children[-1]
last_entry = self.root[last_idx]
if last_entry.isfile:
return last_idx + 1
# Find end of last child's subtree
last_path = parent / last_entry.name
if last_path in self._index:
s, c = self._index[last_path]
return s + c
return last_idx + 1
return start
def apply_update(
self, path: PurePosixPath, new_entries: list[FileEntry]
) -> list[FileEntry]:
"""Apply an update and return the new root. Rebuilds index."""
start, old_entries = self.get(path)
if old_entries == new_entries:
return self.root
new_root = self.root[:]
if start is not None:
del new_root[start : start + len(old_entries)]
if new_entries:
# Rebuild index on modified list to find insert pos
self.root = new_root
self._rebuild()
insert_pos = self.find_insert_pos(path, new_entries[0].isfile)
new_root[insert_pos:insert_pos] = new_entries
self.root = new_root
self._rebuild()
# Recalculate sizes for ancestor folders (including root)
self._recalculate_ancestors(path)
return new_root
def _recalculate_ancestors(self, path: PurePosixPath):
"""Recalculate size/allocated for all ancestors of path, including root."""
# Build list of ancestors from deepest to root
ancestors = []
current = path.parent if path.parts else PurePosixPath()
while True:
ancestors.append(current)
if not current.parts:
break
current = current.parent
# Process from deepest ancestor to root
for ancestor_path in ancestors:
if ancestor_path not in self._index:
continue
start, count = self._index[ancestor_path]
if count == 0:
continue
ancestor = self.root[start]
if ancestor.isfile:
continue # Files don't aggregate
# Sum size/allocated of direct children
total_size = 0
total_allocated = 0
i = start + 1
while i < start + count:
child = self.root[i]
if child.level == ancestor.level + 1:
total_size += child.size
total_allocated += child.allocated
# Skip child's subtree
child_path = ancestor_path / child.name
if child_path in self._index:
_, child_count = self._index[child_path]
i += child_count
else:
i += 1
else:
i += 1
# Update ancestor entry if changed
if ancestor.size != total_size or ancestor.allocated != total_allocated:
self.root[start] = FileEntry(
level=ancestor.level,
name=ancestor.name,
key=ancestor.key,
mtime=ancestor.mtime,
size=total_size,
allocated=total_allocated,
isfile=ancestor.isfile,
)
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
"""Remove child paths if parent is in set."""
if not paths:
return paths
# Filter out root paths (empty or '.') which would cause full tree walks
paths = {p for p in paths if p.parts and p.parts != (".",)}
if not paths:
return set()
# Sort by depth (fewest parts first)
sorted_paths = sorted(paths, key=lambda p: len(p.parts))
result = set()
for path in sorted_paths:
# Check if any ancestor is already in result
is_child = False
for i in range(len(path.parts)):
ancestor = PurePosixPath(*path.parts[:i]) if i > 0 else PurePosixPath()
if ancestor in result:
is_child = True
break
if not is_child:
result.add(path)
return result
# Debounce settings
DEBOUNCE_DELAY = 0.01 # Wait 10ms after last event
DEBOUNCE_MAX = 0.1 # But no more than 100ms total
def watcher(loop):
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
use_inotify = sys.platform == "linux"
inotify_tree = None
modified_flags = frozenset()
if use_inotify:
import inotify.adapters
modified_flags = frozenset(
(
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
)
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
while not quit.is_set(): while not quit.is_set():
i = inotify.adapters.InotifyTree(rootpath.as_posix()) if use_inotify:
import inotify.adapters
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
update_root(loop) update_root(loop)
path_index = PathIndex(state.root[:])
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)
while not quit.is_set(): # Pending changes: path -> {"ws": count, "inotify": count}
t = time.monotonic() dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
# The watching is not entirely reliable, so do a full refresh every 30 seconds first_event_time: float | None = None
if t >= trefresh: last_event_time: float | None = None
break
# Disk usage update def add_dirty(path: PurePosixPath, source: str) -> bool:
if t >= tspace: """Add path to dirty set. Returns True if added, False if redundant."""
tspace = time.monotonic() + 5.0 nonlocal first_event_time, last_event_time
update_space(loop) # Check if already covered by an existing dirty path
# Inotify events, update the tree for existing in dirty_paths:
dirty = False if path == existing or (
rootmod = state.root[:] len(path.parts) > len(existing.parts)
for event in i.event_gen(yield_nones=False, timeout_s=0.1): and path.parts[: len(existing.parts)] == existing.parts
assert event ):
if quit.is_set(): # Count the event even if skipped
return dirty_paths[existing][source] = (
interesting = any(f in modified_flags for f in event[1]) dirty_paths[existing].get(source, 0) + 1
if interesting:
# Update modified path
path = PurePosixPath(event[2]) / event[3]
try:
rel_path = path.relative_to(rootpath)
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:
t = time.monotonic()
dirty = True
# Wait a maximum of 0.2s to push the updates
if dirty and time.monotonic() >= t + 0.2:
break
if dirty and state.root != rootmod:
try:
update = format_update(state.root, rootmod)
with state.lock:
broadcast(update, loop)
state.root = rootmod
except Exception:
logger.exception(
"format_update failed; falling back to full rescan"
) )
# Fallback: full rescan and try diff again; last resort send full root return False
# Remove any paths that would be covered by this new one
covered = {
p
for p in dirty_paths
if len(p.parts) > len(path.parts)
and p.parts[: len(path.parts)] == path.parts
}
# Aggregate counts from covered paths
counts: dict[str, int] = {source: 1}
for p in covered:
for s, c in dirty_paths[p].items():
counts[s] = counts.get(s, 0) + c
del dirty_paths[p]
dirty_paths[path] = counts
now = time.monotonic()
if first_event_time is None:
first_event_time = now
last_event_time = now
return True
while not quit.is_set():
now = time.monotonic()
# Full refresh every 300s
if now >= trefresh:
break
# Disk usage update every 5s
if now >= tspace:
tspace = now + 5.0
update_space(loop)
# Check if we should flush pending changes
should_flush = False
if dirty_paths:
time_since_last = now - last_event_time if last_event_time else 0
time_since_first = now - first_event_time if first_event_time else 0
if (
time_since_last >= DEBOUNCE_DELAY
or time_since_first >= DEBOUNCE_MAX
):
should_flush = True
if should_flush:
paths_to_process = dirty_paths.copy()
dirty_paths.clear()
first_event_time = None
last_event_time = None
# Collapse paths (remove children if parent present)
collapsed = collapse_paths(set(paths_to_process.keys()))
# Process each collapsed path
new_root = path_index.root
for path in collapsed:
new_entries = walk(path)
new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed
if new_root != state.root:
try: try:
fresh = walk(PurePosixPath()) update_msg = format_update(state.root, new_root)
with state.lock:
broadcast(update_msg, loop)
state.root = new_root
except Exception:
logger.exception("format_update failed; full rescan")
try: try:
update = format_update(state.root, fresh) fresh = walk(PurePosixPath())
path_index = PathIndex(fresh)
update_msg = format_update(state.root, fresh)
with state.lock: with state.lock:
broadcast(update, loop) broadcast(update_msg, loop)
state.root = fresh state.root = fresh
except Exception: except Exception:
logger.exception( logger.exception("Fallback failed; sending full root")
"Fallback diff failed; sending full root snapshot"
)
with state.lock: with state.lock:
broadcast(format_root(fresh), loop) broadcast(format_root(fresh), loop)
state.root = fresh state.root = fresh
except Exception:
logger.exception(
"Full rescan failed; dropping this batch of updates"
)
del i # Free the inotify object # Collect events from websocket signals (non-blocking)
try:
while True:
path = _update_queue.get_nowait()
add_dirty(path, "ws")
except queue.Empty:
pass
# Collect inotify events if available (short timeout for responsiveness)
if inotify_tree:
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
if quit.is_set():
return
if not (modified_flags & set(event[1])):
continue
# Extract relative path
path = PurePosixPath(event[2]) / event[3]
try:
rel_path = path.relative_to(rootpath)
except ValueError:
continue
# Skip dotfiles
if any(part.startswith(".") for part in rel_path.parts):
continue
add_dirty(rel_path, "inotify")
# Don't block too long collecting events
now = time.monotonic()
if first_event_time and now - first_event_time >= DEBOUNCE_MAX:
break
else:
# No inotify, just sleep briefly for responsiveness
time.sleep(0.05)
if inotify_tree:
del inotify_tree
def watcher_poll(loop): def start(app):
"""Polling version of the watcher thread."""
while not quit.is_set():
t0 = time.perf_counter()
update_root(loop)
update_space(loop)
dur = time.perf_counter() - t0
if dur > 1.0:
logger.debug(f"Reading the full file list took {dur:.1f}s")
quit.wait(0.1 + 8 * dur)
def start(app, loop):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
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,
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}",
) )
+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>
+31 -30
View File
@@ -16,39 +16,40 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@imengyu/vue3-context-menu": "^1.3.3", "@imengyu/vue3-context-menu": "^1.5.3",
"@vueuse/core": "^10.4.1", "@vueuse/core": "^14.1.0",
"esbuild": "^0.19.5", "esbuild": "^0.27.2",
"lodash": "^4.17.21", "lodash": "^4.17.23",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.23",
"pinia": "^2.1.6", "paskia": "^0.1.2",
"pinia-plugin-persistedstate": "^3.2.0", "pinia": "^3.0.4",
"unplugin-vue-components": "^0.25.2", "pinia-plugin-persistedstate": "^4.7.1",
"vite-svg-loader": "^4.0.0", "unplugin-vue-components": "^31.0.0",
"vue": "^3.3.4", "vite-svg-loader": "^5.1.0",
"vue-router": "^4.2.4" "vue": "^3.5.27",
"vue-router": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.3", "@rushstack/eslint-patch": "^1.15.0",
"@tsconfig/node18": "^18.2.2", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^21.1.3", "@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.10", "@types/lodash-es": "^4.17.12",
"@types/node": "^18.17.17", "@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^4.3.4", "@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.1", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.8.1",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"eslint": "^8.52.0", "eslint": "^9.39.2",
"eslint-plugin-vue": "^9.18.1", "eslint-plugin-vue": "^10.7.0",
"jsdom": "^22.1.0", "jsdom": "^27.4.0",
"npm-run-all2": "^6.0.6", "npm-run-all2": "^8.0.4",
"prettier": "^3.0.3", "prettier": "^3.8.1",
"typescript": "~5.2.0", "typescript": "~5.9.3",
"vite": "^4.4.9", "vite": "^7.3.1",
"vitest": "^0.34.4", "vitest": "^4.0.18",
"vue-tsc": "^1.8.11" "vue-tsc": "^3.2.4"
}, },
"prettier": { "prettier": {
"semi": false, "semi": false,
+117 -21
View File
@@ -1,17 +1,22 @@
<template> <template>
<LoginModal /> <div v-if="store.toast" class="toast-message" @click="store.clearToast()">
{{ store.toast }}
</div>
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
{{ store.error }}
</div>
<SettingsModal /> <SettingsModal />
<UserManagementModal /> <UserManagementModal />
<AccessDeniedModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" primary /> <BreadCrumb :path="path.pathList" primary />
</header> </header>
<main> <main>
<RouterView :path="path.pathList" :query="path.query" /> <RouterView :path="path.pathList" :query="path.query" />
</main> </main>
<footer> <footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<SelectionToolbar :path="path.pathList" />
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload /> <TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download /> <TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer> </footer>
@@ -30,6 +35,8 @@ import Router from '@/router/index'
import type { SortOrder } from './utils/docsort' import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue' import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue' import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
interface Path { interface Path {
path: string path: string
@@ -39,10 +46,10 @@ interface Path {
const store = useMainStore() const store = useMainStore()
const path: ComputedRef<Path> = computed(() => { const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//') const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = p[0].split('/').filter(value => value !== '') const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
const query = p.slice(1).join('//') const query = p.slice(1).join('//')
return { return {
path: p[0], path: p[0] ?? '',
pathList, pathList,
query query
} }
@@ -56,6 +63,7 @@ onUnmounted(watchDisconnect)
const headerMain = ref<typeof HeaderMain | null>(null) const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0 let vert = 0
let timer: any = null let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => { const globalShortcutHandler = (event: KeyboardEvent) => {
if (store.dialog) { if (store.dialog) {
if (timer) { if (timer) {
@@ -69,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT' const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input
if (keyup && event.key.startsWith('Arrow') && timer) {
clearTimeout(timer)
timer = null
}
if (event.repeat) { if (event.repeat) {
if ( if (
event.key === 'ArrowUp' || event.key === 'ArrowUp' ||
@@ -84,19 +99,39 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
//console.log("key pressed", event) //console.log("key pressed", event)
/// Long if-else machina for all keys we handle here /// Long if-else machina for all keys we handle here
let arrow = '' let arrow = ''
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase() const inHeader = !!(event.target as HTMLElement).closest('.headermain')
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
// Handle arrows: in search input with text, only up/down; otherwise all arrows
const searchInput = inHeader && input
const searchHasText = searchInput && (event.target as HTMLInputElement).value
if (event.key.startsWith("Arrow")) {
const dir = event.key.slice(5).toLowerCase()
// In search with text: left/right move cursor, up/down navigate
if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement
}
arrow = dir
}
if (arrow) {
// Arrow key handling - fall through to bottom
}
// Find: process on keydown so that we can bypass the built-in search hotkey // Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Search also on / (UNIX style) // Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.key === '/') { else if (!input && keyup && event.code === 'Slash') {
// Record the actual character for display (varies by keyboard layout)
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
store.prefs.searchHotkey = event.key
}
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Globally close search, clear errors on Escape // Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
headerMain.value!.closeSearch(event) store.clearToast()
headerMain.value!.clearSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} }
else if (!input && keyup && event.key === 'Backspace') { else if (!input && keyup && event.key === 'Backspace') {
@@ -135,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
timer = null timer = null
} }
let f: any let f: any
switch (arrow) { // Arrow navigation - always use fileExplorer for repeatable movement
case 'up': f = () => fileExplorer.up(event); break if (arrow && !keyup) {
case 'down': f = () => fileExplorer.down(event); break const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
case 'left': f = () => fileExplorer.left(event); break const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
case 'right': f = () => fileExplorer.right(event); break
if (inBreadcrumb) {
// Breadcrumb: up→header (no repeat), down→files (with repeat)
if (arrow === 'up') { focusSearch(); f = null }
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
} else if (inHeader) {
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
const idx = items.indexOf(document.activeElement as HTMLElement)
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
else if (arrow === 'down') { focusBreadcrumb(); f = null }
} else {
// File explorer: normal navigation with repeat
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
}
}
} }
if (f && !keyup) { if (f) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100 const t0 = 200, tr = event.altKey ? 20 : 100
f() f()
@@ -149,12 +205,52 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', globalShortcutHandler) // Use capture phase to handle events before they reach target elements
window.addEventListener('keyup', globalShortcutHandler) window.addEventListener('keydown', globalShortcutHandler, true)
window.addEventListener('keyup', globalShortcutHandler, true)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', globalShortcutHandler) window.removeEventListener('keydown', globalShortcutHandler, true)
window.removeEventListener('keyup', globalShortcutHandler) window.removeEventListener('keyup', globalShortcutHandler, true)
}) })
export type { Path } export type { Path }
</script> </script>
<style>
/* Toast notifications - fixed at top center of viewport */
.toast-message {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
padding: 0.75rem 1.5rem;
background: var(--accent-color);
color: #000;
font-weight: bold;
border-radius: 0.25rem;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
cursor: pointer;
max-width: 90vw;
text-align: center;
}
.toast-message.status {
background: #555;
color: #fff;
}
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
z-index: 50;
}
footer > * {
justify-content: center;
}
</style>
+16 -68
View File
@@ -13,8 +13,8 @@
--transition-time: 0.2s; --transition-time: 0.2s;
/* The following are overridden by responsive layouts */ /* The following are overridden by responsive layouts */
--root-font-size: 1rem; --root-font-size: 1rem;
--header-font-size: 1rem; --header-font-size: clamp(0.7rem, 2.5vw, 1rem);
--header-height: 4rem; --header-height: clamp(2.8rem, 10vw, 4rem);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@@ -36,6 +36,7 @@
@media screen and (min-width: 1000px) { @media screen and (min-width: 1000px) {
:root { :root {
--root-font-size: calc(8px + 8 * 100vw / 1000); --root-font-size: calc(8px + 8 * 100vw / 1000);
--header-font-size: 16px;
} }
} }
@media screen and (min-width: 2000px) { @media screen and (min-width: 2000px) {
@@ -43,44 +44,14 @@
--root-font-size: 1.5rem; --root-font-size: 1.5rem;
} }
} }
/* Low (landscape) screens: smaller header */
@media screen and (max-height: 600px) {
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
}
}
@media screen and (max-height: 300px) {
:root {
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
--header-height: calc(1.75 * 16px);
--root-font-size: 0.6rem;
}
header .breadcrumb > * {
padding-top: 14px !important;
padding-bottom: 14px !important;
}
}
@media screen and (orientation: landscape) and (min-width: 700px) { @media screen and (orientation: landscape) and (min-width: 700px) {
/* Breadcrumbs and buttons side by side */ /* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header { header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
header .headermain { order: 1; } header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; } header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
}
} }
@media print { @media print {
:root { :root {
@@ -110,6 +81,7 @@
margin: 0 .5rem 0 1rem !important; margin: 0 .5rem 0 1rem !important;
} }
body#app { body#app {
position: static !important;
height: auto !important; height: auto !important;
} }
main { main {
@@ -165,6 +137,11 @@ body {
font-family: 'Roboto'; font-family: 'Roboto';
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */
overflow: hidden;
/* Fallback for older browsers */
height: 100vh;
height: 100dvh;
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
@@ -214,12 +191,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;
@@ -233,44 +212,13 @@ header nav.headermain {
.spacer { flex-grow: 1 } .spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em } .smallgap { flex-shrink: 1; width: 2em }
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
position: absolute;
font-size: 1rem;
text-align: center;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;
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 {
from {
opacity: 0;
}
30% {
opacity: 0;
}
40% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
opacity: 0;
}
}
.error-message { .error-message {
padding: .5em; padding: .5em;
font-weight: bold; font-weight: bold;
background: var(--accent-color); background: var(--accent-color);
color: #000; color: #000;
} }
.ghost {
opacity: 0.5;
}
+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>
+20 -6
View File
@@ -14,9 +14,12 @@
:class="{ current: !!isCurrent(0) }" :class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)" :aria-current="isCurrent(0)"
@click.prevent="navigate(0)" @click.prevent="navigate(0)"
title="/" @mouseenter="homeTooltip?.startHover"
@mousemove="homeTooltip?.updatePosition"
@mouseleave="homeTooltip?.endHover"
> >
<component :is="home" /> <component :is="home" />
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a> </a>
<template v-for="(location, index) in longest" :key="index"> <template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`" <a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
@@ -24,24 +27,35 @@
:aria-current="isCurrent(index + 1)" :aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)" @click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)" :ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`" @mouseenter="pathTooltips.get(index)?.startHover"
>{{ location }}</a> @mousemove="pathTooltips.get(index)?.updatePosition"
@mouseleave="pathTooltips.get(index)?.endHover"
>{{ location }}<CursorTooltip :ref="el => setPathTooltipRef(index, el)" :text="`/${longest.slice(0, index + 1).join('/')}`">{{ `/${longest.slice(0, index + 1).join('/')}` }}</CursorTooltip></a>
</template> </template>
</nav> </nav>
</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'
import CursorTooltip from './CursorTooltip.vue'
const home = Home
const router = useRouter() const router = useRouter()
const links = [] as Array<HTMLElement> const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el } const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
const setPathTooltipRef = (index: number, el: any) => {
if (el) pathTooltips.value.set(index, el)
else pathTooltips.value.delete(index)
}
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
primary?: boolean primary?: boolean
@@ -54,7 +68,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 +77,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) }
+169
View File
@@ -0,0 +1,169 @@
<template>
<Teleport to="body">
<div v-if="visible" ref="tooltipEl" 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 recent touch to suppress touch-triggered mouse events
let lastTouchTime = 0
</script>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
text: string
delay?: number
}>()
const visible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const tooltipEl = ref<HTMLElement | null>(null)
let settleTimer: ReturnType<typeof setTimeout> | null = null
let lastMoveX = 0
let lastMoveY = 0
// Movement threshold (pixels) - cursor must settle within this radius
const SETTLE_THRESHOLD = 8
const tooltipStyle = computed(() => {
// Constrain to viewport
const pad = 8
let x = mouseX.value
let y = mouseY.value
// Only constrain if we've measured the tooltip
if (tooltipWidth.value > 0 && tooltipHeight.value > 0) {
// Adjust horizontal position if tooltip would overflow right edge
if (x + tooltipWidth.value + pad > window.innerWidth) {
x = window.innerWidth - tooltipWidth.value - pad
}
// Adjust vertical position if tooltip would overflow bottom edge
if (y + tooltipHeight.value + pad > window.innerHeight) {
y = window.innerHeight - tooltipHeight.value - pad
}
// Don't go past left/top edges
x = Math.max(pad, x)
y = Math.max(pad, y)
}
return {
left: `${x}px`,
top: `${y}px`,
}
})
// Track touch events globally to suppress touch-simulated mouse events
const onTouchStart = () => { lastTouchTime = Date.now() }
onMounted(() => document.addEventListener('touchstart', onTouchStart, { passive: true }))
onUnmounted(() => document.removeEventListener('touchstart', onTouchStart))
// Check if event is likely from touch (touch happened within last 500ms)
const isTouchEvent = () => Date.now() - lastTouchTime < 500
const showTooltip = () => {
visible.value = true
globalActive = true
// Measure tooltip after it renders
requestAnimationFrame(() => {
if (tooltipEl.value) {
tooltipWidth.value = tooltipEl.value.offsetWidth
tooltipHeight.value = tooltipEl.value.offsetHeight
}
})
}
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-simulated mouse events
if (isTouchEvent()) return
mouseX.value = e.clientX
mouseY.value = e.clientY
lastMoveX = e.clientX
lastMoveY = e.clientY
}
const updatePosition = (e: MouseEvent) => {
// Ignore touch-simulated mouse events
if (isTouchEvent()) 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 rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: #fff;
pointer-events: none;
font-size: 1rem;
}
</style>
+426
View File
@@ -0,0 +1,426 @@
<template>
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
<div
ref="widgetRef"
class="disk-space-widget"
:class="{ expanded: isExpanded }"
>
<svg viewBox="0 0 150 150" class="pie-svg" preserveAspectRatio="xMidYMid meet">
<defs>
<filter id="pieShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.4)" />
</filter>
<radialGradient id="storageGradient" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="#93e" />
<stop offset="100%" stop-color="#82d" />
</radialGradient>
<radialGradient id="otherGradient" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="#d9f" />
<stop offset="100%" stop-color="#c8e" />
</radialGradient>
<radialGradient id="highlightOverlay" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
<stop offset="100%" stop-color="rgba(0,0,0,0.08)" />
</radialGradient>
</defs>
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
<circle :r="holeRadius" :cx="pieCx" :cy="pieCy" fill="rgba(0,0,0,0.5)" />
<text ref="centerLabelRef" :x="pieCx" :y="pieCy" dy="0.35em" class="pie-center-label" text-anchor="middle">GB</text>
<circle :r="pieRadius" :cx="pieCx" :cy="pieCy" fill="transparent" class="pie-hitarea" @click="handleClick" />
</g>
<g ref="labelsRef" class="pie-labels">
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
<defs>
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
</defs>
<text class="pie-label-sub" fill="#93e">
<textPath :href="'#' + storageLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">{{ storageName }}</textPath>
</text>
<text class="pie-label-sub" :fill="freeColor">
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
</text>
<text class="pie-label-sub" fill="#d9f">
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
</text>
</g>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
const store = useMainStore()
const containerRef = ref<HTMLDivElement | null>(null)
const widgetRef = ref<HTMLDivElement | null>(null)
const labelsRef = ref<SVGGElement | null>(null)
const centerLabelRef = ref<SVGTextElement | null>(null)
const isExpanded = ref(false)
let animationFrame: number | null = null
const BASE_SIZE = 48
const EXPANDED_SCALE = 320 / 48
const ANIM_DURATION = 200
const containerPos = ref({ top: 0, left: 0, width: 0 })
const formatGB = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)
return gb < 10 ? gb.toFixed(1) : `${Math.round(gb)}`
}
// Add dot suffix for ambiguous angles (within 15° of horizontal) on numbers that look same upside down
const fmtSize = (bytes: number, angle: number) => {
const s = formatGB(bytes)
const a = Math.abs(angle % 180)
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
}
const truncateLabel = (name: string, maxLen = 10): string => {
if (name.length <= maxLen) return name
const parts = name.split(/[\s\-_.,;:!?()\[\]{}]+/)
if (parts[0] && parts[0].length <= maxLen) return parts[0]
return name.slice(0, maxLen - 1) + '…'
}
// Calculate max label length based on angular gap to neighbor labels
const storageMaxLen = computed(() => {
const s = store.space
if (!s.disk) return 10
// Sector spans in degrees
const storageSpan = (s.allocated / s.disk) * 360
const freeSpan = (s.free / s.disk) * 360
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
// Angular gap from storage label midpoint to neighbor label midpoints
const gapToFree = (storageSpan + freeSpan) / 2
const gapToOther = (storageSpan + otherSpan) / 2
const minGap = Math.min(gapToFree, gapToOther)
// Allow longer names when there's sufficient gap to both neighbors
if (minGap > 70) return 18
if (minGap > 55) return 14
return 10
})
const storageName = computed(() => {
const name = store.server.name || 'stored'
const maxLen = storageMaxLen.value
// Use full name if it fits within the available space
if (name.length <= maxLen) return name
return truncateLabel(name, 10)
})
const TAU = 2 * Math.PI
const pieCx = 75
const pieCy = 75
const pieRadius = 55
const holeRadius = pieRadius * 0.38
const ringWidth = pieRadius - holeRadius
const midRadius = (pieRadius + holeRadius) / 2
const CIRC = TAU * midRadius
const pieStorageDash = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
})
const pieFreeDash = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
return `${(s.free / s.disk) * CIRC} ${CIRC}`
})
const pieFreeOffsetVal = computed(() => {
const s = store.space
if (!s.disk) return 0
return -(s.allocated / s.disk) * CIRC
})
const freeColor = computed(() => {
const s = store.space
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#ff0'
return '#f00'
})
const PIE_RADIUS = 55
const LABEL_RADIUS = 62
const getPoint = (angle: number, radius: number) => {
const rad = TAU * (angle - 90) / 360
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
}
const sectorInfo = computed(() => {
const s = store.space
if (!s.disk) return {
storage: { angle: 45, pct: 0.25 },
free: { angle: 180, pct: 0.5 },
other: { angle: 270, pct: 0.25 }
}
const storagePct = s.allocated / s.disk
const freePct = s.free / s.disk
const otherPct = (s.used - s.allocated) / s.disk
const storageAngle = storagePct * 180 // midpoint of storage sector
const freeStart = storagePct * 360
const freeAngle = freeStart + freePct * 180
const otherStart = (storagePct + freePct) * 360
const otherAngle = otherStart + otherPct * 180
return {
storage: { angle: storageAngle, pct: storagePct },
free: { angle: freeAngle, pct: freePct },
other: { angle: otherAngle, pct: otherPct }
}
})
const rawAngles = computed(() => ({
storage: sectorInfo.value.storage.angle,
free: sectorInfo.value.free.angle,
other: sectorInfo.value.other.angle
}))
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
// Collision avoidance for curved name labels
const labelLengths = computed(() => ({
storage: storageName.value.length,
free: 4,
other: 5
}))
const getGapForPair = (len1: number, len2: number) => {
return 35 + Math.max(0, len1 + len2 - 8) * 2.5
}
const adjustedLabelAngles = computed(() => {
const angles = rawAngles.value
const lens = labelLengths.value
const labels = [
{ id: 'storage', angle: angles.storage, len: lens.storage },
{ id: 'free', angle: angles.free, len: lens.free },
{ id: 'other', angle: angles.other, len: lens.other }
]
labels.sort((a, b) => a.angle - b.angle)
for (let iterations = 0; iterations < 15; iterations++) {
let moved = false
for (let i = 0; i < labels.length; i++) {
const current = labels[i]!
const next = labels[(i + 1) % labels.length]!
let angleDiff = next.angle - current.angle
if (angleDiff < 0) angleDiff += 360
const requiredGap = getGapForPair(current.len, next.len)
if (angleDiff < requiredGap) {
const push = (requiredGap - angleDiff) / 2
current.angle = (current.angle - push + 360) % 360
next.angle = (next.angle + push) % 360
moved = true
}
}
if (!moved) break
}
const result: Record<string, number> = {}
for (const l of labels) result[l.id] = l.angle
return result
})
// Arc path for curved text labels (CW for top half, CCW for bottom half)
const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
const radius = LABEL_RADIUS
// Scale arc span based on label length: ~6° per character, minimum 45°
const arcSpan = Math.max(45, labelLen * 6)
const isBottom = centerAngle > 90 && centerAngle <= 270
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
const start = getPoint(startAngle, radius)
const end = getPoint(endAngle, radius)
const sweep = isBottom ? 0 : 1
return {
id: `label-path-${id}`,
d: `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 ${sweep} ${end.x} ${end.y}`
}
}
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
const handleClick = () => isExpanded.value ? collapse() : expand()
const applyAnimState = (t: number, opacity: number) => {
const widget = widgetRef.value
const labels = labelsRef.value
const centerLabel = centerLabelRef.value
if (!widget) return
const scale = 1 + (EXPANDED_SCALE - 1) * t
// Move top-right corner of widget to top-right corner of viewport
const targetX = window.innerWidth - containerPos.value.left - containerPos.value.width
const targetY = -containerPos.value.top
widget.style.transform = `translate(${targetX * t}px, ${targetY * t}px) scale(${scale})`
if (labels) labels.style.opacity = String(opacity)
if (centerLabel) centerLabel.style.opacity = String(opacity)
}
const animate = (duration: number, expanding: boolean, onComplete?: () => void) => {
const startTime = performance.now()
const tick = (now: number) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
const t = expanding ? eased : 1 - eased
applyAnimState(t, t) // opacity follows position
if (progress < 1) {
animationFrame = requestAnimationFrame(tick)
} else {
animationFrame = null
onComplete?.()
}
}
animationFrame = requestAnimationFrame(tick)
}
const expand = () => {
if (animationFrame) cancelAnimationFrame(animationFrame)
if (containerRef.value) {
const rect = containerRef.value.getBoundingClientRect()
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
}
isExpanded.value = true
animate(ANIM_DURATION, true)
}
const collapse = () => {
if (animationFrame) cancelAnimationFrame(animationFrame)
if (containerRef.value) {
const rect = containerRef.value.getBoundingClientRect()
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
}
animate(ANIM_DURATION, false, () => {
isExpanded.value = false
})
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isExpanded.value) collapse()
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
// Initialize labels as hidden
if (labelsRef.value) labelsRef.value.style.opacity = '0'
if (centerLabelRef.value) centerLabelRef.value.style.opacity = '0'
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
if (animationFrame) cancelAnimationFrame(animationFrame)
})
</script>
<style scoped>
.disk-space-container {
position: relative;
width: 3em;
height: 3em;
outline: none;
}
.disk-space-container:focus .disk-space-widget:not(.expanded) {
filter: brightness(1);
}
.disk-space-widget {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
will-change: transform;
filter: brightness(0.85);
transition: filter 0.2s ease;
transform-origin: top right;
}
.disk-space-widget:hover,
.disk-space-widget:focus {
filter: brightness(1);
}
.disk-space-widget.expanded {
pointer-events: none;
filter: none;
}
.disk-space-widget.expanded:hover,
.disk-space-widget.expanded:focus {
filter: none;
}
.pie-svg {
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.pie-hitarea {
pointer-events: auto;
cursor: pointer;
}
.pie-label-inner {
fill: #eee;
font-size: 12px;
font-weight: 700;
stroke: #000;
stroke-width: 0.5px;
paint-order: stroke fill;
}
.pie-center-label {
fill: #eee;
font-size: 12px;
font-weight: 600;
}
.pie-label-sub {
font-size: 14px;
font-weight: 600;
font-variant: small-caps;
text-transform: lowercase;
stroke: #000;
stroke-width: 1px;
paint-order: stroke fill;
}
</style>
+25 -25
View File
@@ -1,11 +1,12 @@
<template> <template>
<SvgButton name="download" data-tooltip="Download" @click="download" /> <SvgButton name="download" tooltip="Download" @click="download" />
</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 { zipName } from '@/utils/fileutil'
const store = useMainStore() const store = useMainStore()
@@ -96,7 +97,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
const writable = await fileHandle.createWritable() const writable = await fileHandle.createWritable()
const url = `/files/${rel}` const url = `/files/${rel}`
console.log('Fetching', url) console.log('Fetching', url)
const res = await fetch(url) const res = await apiFetch(url)
if (!res.ok) { if (!res.ok) {
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}` store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`) throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
@@ -105,7 +106,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
++store.dprogress.fileidx ++store.dprogress.fileidx
const reader = res.body.getReader() const reader = res.body.getReader()
await writable.truncate(0) await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now() store.dprogress.tlast = Date.now()
while (true) { while (true) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
@@ -130,43 +130,43 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
statReset() statReset()
} }
const download = async () => { const zipdl = (sel: SelectedItems) => {
const items = sel.keys.map(k => sel.docs[k]!)
const name = zipName(items)
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.showToast(`Downloading ${name}.zip`)
store.selected.clear()
}
const download = async (e: MouseEvent) => {
const sel = store.selectedFiles const sel = store.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) { if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) store.showToast('No existing files selected')
store.error = 'No existing files selected'
store.selected.clear() store.selected.clear()
return return
} }
// Plain old a href download if only one file (ignoring any folders) // Single file: direct download
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) { if (files.length === 1) {
store.selected.clear() store.selected.clear()
store.error = "Single file via browser downloads" store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0][1]}`) return linkdl(`/files/${files[0]![1]}`)
} }
// Use FileSystem API if multiple files and the browser supports it // Alt+click: download to folder (hidden feature)
if ('showDirectoryPicker' in window) { if (e.altKey && 'showDirectoryPicker' in window) {
try { try {
// @ts-ignore // @ts-ignore
const handle = await window.showDirectoryPicker({ const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle) await filesystemdl(sel, handle)
store.selected.clear() store.selected.clear()
return
} catch (e) { } catch (e) {
console.error('Download to folder aborted', e) console.error('Download to folder failed', e)
store.showToast('Download to folder failed')
} }
return
} }
// Otherwise, zip and download // Default: ZIP download
console.log("Falling back to zip download") zipdl(sel)
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.error = "Downloading as ZIP via browser downloads"
store.selected.clear()
} }
</script> </script>
+9 -5
View File
@@ -1,8 +1,9 @@
<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.document.length === 0">Waiting for File List</p> <p v-else-if="!store.connected">No Connection</p>
<p v-else-if="store.documentCount === 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>
<p v-else>Empty folder</p> <p v-else>Empty folder</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>
+125 -19
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>
@@ -28,7 +28,7 @@
<tr <tr
:id="`file-${doc.key}`" :id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }" :class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key" @click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)" @contextmenu.prevent="contextMenu($event, doc)"
> >
@@ -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>
@@ -72,7 +72,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@@ -124,6 +124,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0,
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -134,6 +135,17 @@ defineExpose({
isCursor() { isCursor() {
return store.cursor && editing.value === null return store.cursor && editing.value === null
}, },
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() { cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
@@ -149,7 +161,12 @@ defineExpose({
}, },
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) }, down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() }, left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) {
router.back()
}
},
right(ev: KeyboardEvent) { right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click() if (a) a.click()
@@ -175,7 +192,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)
} }
@@ -189,9 +206,17 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
if (moveto === N) focusBreadcrumb() // When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
@@ -209,7 +234,7 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
@@ -249,14 +274,15 @@ const mkdir = (doc: Doc, name: string) => {
} }
} }
}) })
// We should get an update from watch but this is quicker
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
} }
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: () => {
@@ -290,13 +316,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => { const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
ContextMenu.showContextMenu({ const items = [
x: ev.x, y: ev.y, items: [ { label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: 'Rename', onClick: () => { editing.value = doc } }, { label: '🔗 Copy Link', onClick: () => copyLink(doc) },
], ]
}) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
</script> </script>
@@ -392,9 +501,6 @@ tbody tr.cursor {
.sortcolumn:hover { .sortcolumn:hover {
cursor: pointer; cursor: pointer;
} }
.sortcolumn:hover::after {
color: var(--accent-color);
}
.sortcolumn { .sortcolumn {
padding-right: 1.5rem; padding-right: 1.5rem;
} }
+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>
+29 -7
View File
@@ -1,22 +1,44 @@
<template> <template>
<td class="size right" :class=sizeClass>{{ doc.sizedisp }}</td> <td
class="size right"
:class="sizeClass"
@mouseenter="doc.sparseIndicator && tooltip?.startHover($event)"
@mousemove="doc.sparseIndicator && tooltip?.updatePosition($event)"
@mouseleave="doc.sparseIndicator && tooltip?.endHover()"
>
<SparseIndicator :doc="doc" class="before-size" />{{ doc.sizedisp }}
<CursorTooltip v-if="doc.sparseIndicator" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</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 { computed, ref } from 'vue'
import { formatSize } from '@/utils'
const sizeClass = computed(() => { import SparseIndicator from './SparseIndicator.vue'
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0] import CursorTooltip from './CursorTooltip.vue'
return +unit ? "bytes" : unit
})
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const sizeClass = computed(() => {
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
return +unit ? "bytes" : unit
})
const tooltipText = computed(() => {
const { size, allocated } = props.doc
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
})
</script> </script>
<style scoped> <style scoped>
.before-size {
margin-right: 0.2em;
}
.size.empty { color: #555 } .size.empty { color: #555 }
.size.bytes { color: #77a } .size.bytes { color: #77a }
.size.kB { color: #474 } .size.kB { color: #474 }
+138 -38
View File
@@ -2,18 +2,14 @@
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key> <template v-for="(doc, index) in documents" :key=doc.key>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)"> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<template v-if=showFolderBreadcrumb(index)> <GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
<div class="spacer"></div>
</template>
</GalleryFigure>
</template> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS' import { connect, controlUrl } from '@/repositories/WS'
@@ -55,10 +51,12 @@ const rename = (doc: Doc, newName: string) => {
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columns = computed(() => { const columnCount = ref(1)
if (!gallery.value) return 1 const updateColumns = () => {
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length if (!gallery.value) return
}) columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
}
const columns = computed(() => columnCount.value)
defineExpose({ defineExpose({
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -69,6 +67,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0,
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -83,6 +82,17 @@ defineExpose({
isCursor() { isCursor() {
return store.cursor && editing.value === null return store.cursor && editing.value === null
}, },
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() { cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
@@ -131,7 +141,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)
} }
@@ -145,9 +155,17 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
if (moveto === N) focusBreadcrumb() // When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
@@ -163,17 +181,26 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
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, {
@@ -196,26 +223,17 @@ const mkdir = (doc: Doc, name: string) => {
} }
} }
}) })
// We should get an update from watch but this is quicker
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
} }
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({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({ const allSelected = computed({
get: () => { get: () => {
return ( return (
@@ -237,13 +255,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => { const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
ContextMenu.showContextMenu({ const items = [
x: ev.x, y: ev.y, items: [ { label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: 'Rename', onClick: () => { editing.value = doc } }, { label: '🔗 Copy Link', onClick: () => copyLink(doc) },
], ]
}) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
</script> </script>
@@ -254,13 +355,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
grid-template-rows: repeat(minmax(auto, 15em));
align-items: end; align-items: end;
} }
.breadcrumb { .folder-indicator {
border-radius: .5em 0 0 .5em; grid-column: 1 / -1;
} }
.spacer { .folder-start {
flex: 0 1000000000 4rem; grid-column-start: 1;
} }
</style> </style>
+40 -4
View File
@@ -1,9 +1,12 @@
<template> <template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1 <a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }" :class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@contextmenu.stop @contextmenu.stop
@focus.stop="store.cursor = doc.key" @focus.stop="store.cursor = doc.key"
@click=onclick @click=onclick
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
> >
<figure> <figure>
<slot></slot> <slot></slot>
@@ -15,19 +18,27 @@
</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 }}<SparseIndicator :doc="doc" class="after-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>
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</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 { formatSize } from '@/utils'
import MediaPreview from '@/components/MediaPreview.vue' import MediaPreview from '@/components/MediaPreview.vue'
import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
@@ -40,6 +51,14 @@ 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 sparseText = computed(() => {
const { size, allocated } = props.doc
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
})
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) ev.preventDefault()
@@ -48,6 +67,20 @@ const onclick = (ev: Event) => {
</script> </script>
<style scoped> <style scoped>
.tooltip-name {
font-weight: 600;
text-align: center;
}
.tooltip-details {
text-align: center;
}
.tooltip-sparse {
text-align: center;
opacity: 0.8;
}
.after-name {
margin-left: 0.3em;
}
figure { figure {
max-height: 15em; max-height: 15em;
position: relative; position: relative;
@@ -57,12 +90,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%;
+154 -51
View File
@@ -1,86 +1,126 @@
<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" tooltip="New folder"
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}" @click="() => { store.fileExplorer!.newFolder() }"
/> />
<slot></slot> <div class="smallgap"></div>
<div class="spacer smallgap"></div> <SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<template v-if="showSearchInput"> <div class="search-group">
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input <input
ref="search" ref="search"
type="search" type="search"
:value="query" :value="query"
@input="updateSearch" @input="updateSearch"
placeholder="Find files" @keydown.escape="clearSearch"
class="margin-input"
/> />
</template> <span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" /> </div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" /> <div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" />
<SvgButton name="cog" @click="settingsMenu" /> <SvgButton name="cog" @click="settingsMenu" />
</nav> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { ref, nextTick, watchEffect } from 'vue' import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref } 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';
import DiskSpace from './DiskSpace.vue'
const store = useMainStore() const store = useMainStore()
const showSearchInput = ref<boolean>(false) const ssoStore = useSsoAuthStore()
const search = ref<HTMLInputElement | null>() const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
}>() }>()
const closeSearch = (ev: Event) => { const clearSearch = (ev: Event) => {
if (!showSearchInput.value) return // Already closing const input = search.value
showSearchInput.value = false if (input) {
input.value = ''
updateSearch(ev)
}
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus() breadcrumb.focus()
updateSearch(ev)
} }
const focusSearch = () => {
search.value?.focus()
}
// Track pending route update
let pendingRouteUpdate: number | null = null
const updateSearch = (ev: Event) => { const updateSearch = (ev: Event) => {
const q = (ev.target as HTMLInputElement).value const q = (ev.target as HTMLInputElement).value
let p = props.path.join('/') const loc = props.path.join('/')
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/') // Start search immediately via store (worker handles it async)
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') store.search(q, loc)
if (!props.query && q) router.push(u)
else router.replace(u) // Cancel any pending route update
} if (pendingRouteUpdate !== null) {
const toggleSearchInput = (ev: Event) => { cancelAnimationFrame(pendingRouteUpdate)
showSearchInput.value = !showSearchInput.value }
if (!showSearchInput.value) return closeSearch(ev)
nextTick(() => { // Schedule route update - will be cancelled if user types again
const input = search.value pendingRouteUpdate = requestAnimationFrame(() => {
if (input) input.focus() pendingRouteUpdate = null
let p = loc
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Use replace to avoid building up history for each keystroke
router.replace(u)
}) })
} }
watchEffect(() => {
if (props.query) showSearchInput.value = true const toggleSearchInput = () => {
}) search.value?.focus()
}
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
if (store.user.privileged) { // For external auth, show user name as link to /auth/
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }}) if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' }
})
} }
// Only show password change for non-SSO users
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
}
if (store.user.privileged) {
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) { if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else { } else if (store.server.public) {
items.push({ label: 'Login', onClick: () => store.loginDialog() }) // Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
}})
} }
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
@@ -90,7 +130,7 @@ const settingsMenu = (e: Event) => {
} }
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
closeSearch, clearSearch,
}) })
</script> </script>
@@ -101,13 +141,76 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
z-index: 10; z-index: 10;
min-height: 3em;
} }
input[type='search'] { .search-group {
background: var(--input-background); position: relative;
color: var(--input-color); display: flex;
border: 0; align-items: center;
border-radius: 0.1em; background: rgba(255, 255, 255, 0.1);
border-radius: 1.5em;
padding: 0 0.3em;
transition: background 0.2s ease;
flex: 1 1 auto;
min-width: 2.5em;
max-width: 20em;
}
.search-group:hover,
.search-group:focus-within {
background: rgba(255, 255, 255, 0.2);
}
.search-group:focus-within {
box-shadow: 0 0 0 2px var(--accent-color, #f80);
}
.search-group:hover :deep(button.action-button),
.search-group:focus-within :deep(button.action-button) {
transform: scale(1.1);
}
.search-group:hover :deep(button.action-button svg),
.search-group:focus-within :deep(button.action-button svg) {
fill: #fff;
}
.search-group:focus-within .search-hint {
opacity: 0;
pointer-events: none;
}
.search-group :deep(.action-button) {
width: 2.2em;
height: 2.2em;
min-width: 1.5em;
min-height: 1.5em;
flex-shrink: 0;
}
.search-group input[type='search'] {
background: transparent;
color: var(--header-color);
border: none;
outline: none; outline: none;
max-width: 15ch; padding: 0.2em 0.5em 0.2em 0;
font-size: inherit;
flex: 1 1 3em;
min-width: 0;
width: 100%;
}
.search-hint {
position: absolute;
right: 0.5em;
font-family: system-ui, sans-serif;
font-size: 1em;
font-weight: 700;
color: #333;
background: #ccc;
border: 1px solid #999;
border-radius: 0.3em;
padding: 0 0.45em;
line-height: 1.4;
cursor: pointer;
transition: opacity 0.15s ease;
display: none;
}
@media (hover: hover) and (pointer: fine) {
.search-hint {
display: block;
}
} }
</style> </style>
@@ -1,65 +0,0 @@
<template>
<template v-if="store.selected.size">
<div class="smallgap"></div>
<p class="select-text">{{ store.selected.size }} selected </p>
<DownloadButton />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" />
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()"></button>
</template>
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => {
const sel = store.selectedFiles
const msg = {
op,
sel: sel.keys.map(key => {
const doc = sel.docs[key]
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
store.error = res.error.message
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
store.selected.clear()
return
} else console.log('Unknown control response', msg, res)
}
})
control.onopen = () => {
control.send(JSON.stringify(msg))
}
}
</script>
<style>
.select-text {
color: var(--accent-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
</style>
-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>
+88 -5
View File
@@ -1,8 +1,15 @@
<template> <template>
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt=""> <div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
<SpinnerIcon />
</div>
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" 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" :class="{ pending: !doc.complete }">
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<video v-else ref=vid :src=doc.url 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,15 +20,16 @@
<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, Spinner as SpinnerIcon } 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)
const media = computed(() => aud.value || vid.value) const media = computed(() => aud.value || vid.value)
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
quality: string quality: string
}>() }>()
const previewSrc = computed(() => props.doc.previewurl ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` : '')
const onplay = () => { const onplay = () => {
if (!media.value) return if (!media.value) return
@@ -33,6 +41,13 @@ const onpaused = () => {
media.value.controls = false media.value.controls = false
media.value.removeAttribute('data-playing') media.value.removeAttribute('data-playing')
} }
const applyPoster = (el: HTMLVideoElement) => {
if (props.doc.complete) {
el.poster = previewSrc.value
} else {
el.removeAttribute('poster')
}
}
let fscurrent: HTMLVideoElement | null = null let fscurrent: HTMLVideoElement | null = null
const next = () => { const next = () => {
if (!media.value) return if (!media.value) return
@@ -42,7 +57,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
} }
} }
@@ -66,7 +81,7 @@ const next = () => {
if (!elem.paused) fscurrent.play() if (!elem.paused) fscurrent.play()
fscurrent = null fscurrent = null
elem.src = props.doc.url elem.src = props.doc.url
elem.poster = poster.value applyPoster(elem)
onpaused() onpaused()
}, {once: true}) }, {once: true})
} }
@@ -100,6 +115,7 @@ defineExpose({
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext) 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 showProgress = () => !props.doc.complete && (preview() || props.doc.img)
const preview = () => ( const preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].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 &&
@@ -116,6 +132,29 @@ img, embed, .icon, audio, video {
max-height: 100%; max-height: 100%;
border-radius: calc(.5em / 8); border-radius: calc(.5em / 8);
} }
.preview-progress {
display: flex;
align-items: center;
justify-content: center;
min-width: 50%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
}
.preview-progress :deep(svg) {
width: 4.5em;
height: 4.5em;
opacity: 0.8;
animation: media-preview-spin 0.9s linear infinite;
}
@keyframes media-preview-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.folder::before { .folder::before {
content: '📁'; content: '📁';
} }
@@ -165,4 +204,48 @@ 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%;
min-height: 6em;
aspect-ratio: 16 / 9;
max-width: 100%;
max-height: 100%;
}
.video-container.pending {
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
}
.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>
+239 -50
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">
<h1 v-if="props.title">{{ props.title }}</h1> <div class="modal-dialog" :id="props.name" ref="dialog">
<div> <h1 v-if="props.title">{{ props.title }}</h1>
<slot> <div class="modal-content">
Dialog with no content <slot>
<button @click=close>OK</button> Dialog with no content
</slot> <button @click="close">OK</button>
</slot>
</div>
</div> </div>
</dialog> </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(() => { overlay.value?.focus()
const input = dialog.value!.querySelector('input') 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>
@@ -0,0 +1,211 @@
<template>
<div class="selection-bar" v-if="store.selected.size">
<div class="select-info">
<template v-if="selectionDisplay.folders.length <= 5">
<span class="select-folders">
<template v-for="(folder, i) in selectionDisplay.folders" :key="folder.path">
<span v-if="i > 0" class="folder-sep">, </span>
<a :href="'/#/' + folder.path" class="folder-link" @click.prevent="navigateTo(folder.path)">{{ folder.name }}</a>
</template>
</span>
</template>
<template v-else>
<span class="select-count">{{ store.selected.size }} items from {{ selectionDisplay.numFolders }} folders</span>
</template>
</div>
<span class="select-size">{{ selectionDisplay.size }}</span>
<DownloadButton />
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
<button
class="action-button unselect"
@click="store.selected.clear()"
@mouseenter="unselectTooltip?.startHover"
@mousemove="unselectTooltip?.updatePosition"
@mouseleave="unselectTooltip?.endHover"
> selection</button>
</div>
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
import router from '@/router'
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
const dst = computed(() => props.path!.join('/'))
const navigateTo = (path: string) => {
router.push('/' + path)
}
// Truncate long names to reasonable length
const truncateName = (name: string, maxLen = 20): string => {
if (name.length <= maxLen) return name
return name.slice(0, maxLen - 1) + '…'
}
interface FolderInfo {
name: string
path: string
count: number
}
interface SelectionDisplay {
folders: FolderInfo[]
numFolders: number
size: string
}
const selectionDisplay = computed<SelectionDisplay>(() => {
const sel = store.selectedFiles
// Calculate total size
const totalSize = sel.keys.reduce((sum, key) => {
const doc = sel.docs[key]
return sum + (doc ? doc.size : 0)
}, 0)
const sizeStr = formatSize(totalSize)
// Group by folder location, storing file names
const folderGroups = new Map<string, string[]>()
for (const key of sel.keys) {
const doc = sel.docs[key]
if (!doc) continue
const loc = doc.loc || ''
if (!folderGroups.has(loc)) folderGroups.set(loc, [])
folderGroups.get(loc)!.push(doc.name)
}
const numFolders = folderGroups.size
const folders = Array.from(folderGroups.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([loc, names]) => {
const count = names.length
// For single file, display truncated filename; for multiple, display folder name with count
let displayName: string
if (count === 1) {
displayName = truncateName(names[0]!)
} else {
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
displayName = `${truncateName(folderName)} (${count})`
}
return {
name: displayName,
path: loc,
count
}
})
return {
folders,
numFolders,
size: sizeStr
}
})
const op = (opName: string, dst?: string) => {
const sel = store.selectedFiles
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
const msg = {
op: opName,
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
store.error = res.error.message
// Restore hidden items on error
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
store.selected.clear()
return
} else console.log('Unknown control response', msg, res)
}
})
control.onopen = () => {
control.send(JSON.stringify(msg))
}
}
</script>
<style>
.selection-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.3em 0.5em;
background: transparent;
color: var(--header-color);
font-size: var(--header-font-size);
gap: 0.3em;
flex-wrap: nowrap;
max-width: 100%;
}
.select-info {
color: var(--accent-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
flex-shrink: 1;
min-width: 0;
}
.select-count {
font-weight: 500;
}
.select-folders {
display: inline;
}
.folder-link,
.folder-link:link,
.folder-link:visited,
.folder-link:active {
color: var(--accent-color);
text-decoration: none;
cursor: pointer;
}
.folder-link:hover {
text-decoration: underline;
color: var(--accent-color);
}
.folder-sep {
color: var(--header-color);
opacity: 0.6;
}
.select-size {
color: var(--header-color);
opacity: 0.8;
font-family: 'Roboto Mono', monospace;
font-size: 0.9em;
margin-left: 0.5em;
}
</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.showToast('⚠️ Current password is required')
password.value!.focus() password.value!.focus()
return return
} }
@@ -84,7 +79,7 @@ const submit = async (ev: Event) => {
close() close()
} catch (error) { } catch (error) {
const httpError = error as ISimpleError const httpError = error as ISimpleError
form.error = httpError.message || '🛑 Unknown error' store.showToast(httpError.message || '🛑 Unknown error')
} finally { } finally {
confirmLoading.value = false confirmLoading.value = false
} }
@@ -92,36 +87,5 @@ const submit = async (ev: Event) => {
</script> </script>
<style scoped> <style scoped>
.login-container { /* Component-specific styles - most styling comes from ModalDialog.vue global styles */
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style> </style>
@@ -0,0 +1,17 @@
<template>
<span v-if="doc.sparseIndicator" class="sparse-indicator">{{ doc.sparseIndicator }}</span>
</template>
<script setup lang="ts">
import { Doc } from '@/repositories/Document'
defineProps<{
doc: Doc
}>()
</script>
<style scoped>
.sparse-indicator {
opacity: 0.7;
}
</style>
+33 -10
View File
@@ -1,18 +1,30 @@
<template> <template>
<button class="action-button"> <button
<component :is="icon" /> class="action-button"
:tabindex="tabindex"
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>
<component :is="icons[name]" />
<slot></slot> <slot></slot>
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent } from 'vue' import { icons, type IconName } from '@/assets/svg'
import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{ const props = defineProps<{
name: string name: IconName
tooltip?: string
tabindex?: string | number
}>() }>()
const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`)) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = props.tooltip ?? ''
</script> </script>
<style> <style>
@@ -22,18 +34,29 @@ const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`)
color: #ccc; color: #ccc;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
padding: 0.2em; margin: 0 0.2em;
width: 3em; padding: 0;
height: 3em; width: 2.7em;
height: 2.7em;
min-width: 1.9em;
min-height: 1.9em;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
} }
.action-button:hover, .action-button:hover,
.action-button:focus { .action-button:focus {
color: #fff; color: #fff;
transform: scale(1.1); transform: scale(1.1);
} }
svg { .action-button svg {
fill: #ccc; fill: #ccc;
transform: fill 0.2s ease; transition: fill 0.2s ease;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
} }
.action-button:hover svg, .action-button:hover svg,
.action-button:focus svg { .action-button:focus svg {
+3 -1
View File
@@ -53,15 +53,17 @@ const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value <
<style scoped> <style scoped>
.transferprogress { .transferprogress {
--bar: var(--accent-color); --bar: var(--accent-color);
--nobar: var(--header-background); --nobar: transparent;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
color: var(--primary-color); color: var(--primary-color);
width: 100%; width: 100%;
} }
.statustext { .statustext {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
margin: 0 .5em; margin: 0 .5em;
padding: 0.5rem 0; padding: 0.5rem 0;
} }
+143 -20
View File
@@ -3,16 +3,20 @@
<input ref="fileInput" @change="uploadHandler" type="file" multiple> <input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory> <input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template> </template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" /> <SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" /> <SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
</template> </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'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils'; import { collator } from '@/utils';
import { onMounted, onUnmounted, reactive, ref } from 'vue' import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const fileInput = ref() const fileInput = ref()
const folderInput = ref() const folderInput = ref()
const store = useMainStore() const store = useMainStore()
@@ -72,24 +76,53 @@ function uploadHandler(event: Event) {
const uploadFiles = (infiles: File[]) => { const uploadFiles = (infiles: File[]) => {
const loc = props.path!.join('/') const loc = props.path!.join('/')
let files = [] let files = []
let folderName = ''
for (const file of infiles) { for (const file of infiles) {
const relPath = file.webkitRelativePath || file.name
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
files.push({ files.push({
file, file,
cloudName: loc + '/' + (file.webkitRelativePath || file.name), cloudName: loc + '/' + relPath,
cloudPos: 0, cloudPos: 0,
}) })
} }
uploadCloudFiles(files) uploadCloudFiles(files)
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
} }
const uploadCloudFiles = (files: CloudFile[]) => { const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.')) const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) { if (dotfiles.length) {
store.error = "Won't upload dotfiles" store.showToast("Won't upload dotfiles")
console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.')) files = files.filter(f => !f.cloudName.includes('/.'))
} }
if (!files.length) return if (!files.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000)
const docs = getDocuments()
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
// Also check existing ghosts
for (const g of store.ghosts) {
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
}
const added = new Set<string>()
for (const f of files) {
const lastSlash = f.cloudName.lastIndexOf('/')
const loc = lastSlash > 0 ? f.cloudName.slice(0, lastSlash) : ''
const name = f.cloudName.slice(lastSlash + 1)
// Ghost folders for intermediate directories
const parts = loc.split('/')
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
added.add(folderPath)
}
}
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName)
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
}
// @ts-ignore // @ts-ignore
upqueue = [...upqueue, ...files] upqueue = [...upqueue, ...files]
statsAdd(files) statsAdd(files)
@@ -98,6 +131,7 @@ const uploadCloudFiles = (files: CloudFile[]) => {
const cancelUploads = () => { const cancelUploads = () => {
upqueue = [] upqueue = []
blockQueue = []
statReset() statReset()
} }
@@ -129,16 +163,42 @@ setInterval(() => {
store.uprogress.statdur *= .9 store.uprogress.statdur *= .9
} }
}, 100) }, 100)
// Track uploaded bytes for each file to handle out-of-order uploads
const uploadedBytes = new Map<string, Set<number>>()
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 !== store.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()
store.uprogress.xfer = store.uprogress.filestart + end
store.uprogress.filepos = end // Track which bytes have been uploaded (using start to end range)
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
const uploaded = uploadedBytes.get(name)!
const blockSize = 1 << 20
// Mark all bytes in this block as uploaded
for (let i = start; i < end; i += blockSize) {
uploaded.add(i)
}
// Calculate total uploaded bytes for progress
let totalUploaded = 0
for (let i = 0; i < size; i += blockSize) {
if (uploaded.has(i)) totalUploaded += blockSize
}
store.uprogress.xfer = store.uprogress.filestart + totalUploaded
store.uprogress.filepos = totalUploaded
store.uprogress.statbytes += end - start store.uprogress.statbytes += end - start
store.uprogress.statdur += now - store.uprogress.tlast store.uprogress.statdur += now - store.uprogress.tlast
store.uprogress.tlast = now store.uprogress.tlast = now
// File finished?
if (end === size) { // Check if file is fully uploaded by examining the block queue
const currentUpload = blockQueue[0]
if (!currentUpload) return
if (currentUpload.file.cloudName === name && currentUpload.blockIndex >= currentUpload.blocks.length) {
// All blocks for this file have been uploaded
uploadedBytes.delete(name) // Clean up tracking
store.uprogress.filestart += size store.uprogress.filestart += size
statNextFile() statNextFile()
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset() if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
@@ -165,6 +225,42 @@ const statsAdd = (f: CloudFile[]) => {
} }
let upqueue = [] as CloudFile[] let upqueue = [] as CloudFile[]
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
const BLOCK_SIZE = 1 << 20 // 1 MiB
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
const FINAL_BLOCKS_COUNT = 2
const fileSize = file.file.size
const blocks: {start: number, end: number}[] = []
if (fileSize >= MIN_SIZE_FOR_REORDER) {
// File is large enough, prioritize final blocks
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
// Add final blocks first
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
const start = finalBlocksStart + (i * BLOCK_SIZE)
const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({start, end})
}
// Add remaining blocks from beginning
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
blocks.push({start, end})
}
} else {
// File is smaller, use sequential upload
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({start, end})
}
}
return blocks
}
// TODO: Rewrite as WebSocket class // TODO: Rewrite as WebSocket class
const WSCreate = async () => await new Promise<WebSocket>(resolve => { const WSCreate = async () => await new Promise<WebSocket>(resolve => {
const ws = connect(uploadUrl, { const ws = connect(uploadUrl, {
@@ -202,31 +298,58 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
ws.send(data) ws.send(data)
} }
}) })
type BlockUpload = {
file: CloudFile
blocks: {start: number, end: number}[]
blockIndex: number
}
let blockQueue = [] as BlockUpload[]
const worker = async () => { const worker = async () => {
const ws = await WSCreate() const ws = await WSCreate()
while (upqueue.length) { while (blockQueue.length) {
const f = upqueue[0] const upload = blockQueue[0]!
const start = f.cloudPos const f = upload.file
const end = Math.min(f.file.size, start + (1<<20)) const block = upload.blocks[upload.blockIndex]!
const control = { name: f.cloudName, size: f.file.size, start, end }
const data = f.file.slice(start, end) const control = { name: f.cloudName, size: f.file.size, start: block.start, end: block.end }
f.cloudPos = end const data = f.file.slice(block.start, block.end)
// Note: files may get modified during I/O // Note: files may get modified during I/O
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object // @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
ws.sendMsg(control) ws.sendMsg(control)
// @ts-ignore // @ts-ignore
await ws.sendData(data) await ws.sendData(data)
if (f.cloudPos === f.file.size) upqueue.shift()
// Move to next block
upload.blockIndex++
if (upload.blockIndex >= upload.blocks.length) {
// File upload complete
blockQueue.shift()
}
} }
if (upqueue.length) startWorker() if (blockQueue.length) startWorker()
store.uprogress.status = "idle" store.uprogress.status = "idle"
workerRunning = false workerRunning = false
} }
let workerRunning: any = false let workerRunning: any = false
const startWorker = () => { const startWorker = () => {
if (workerRunning === false) workerRunning = setTimeout(() => { if (workerRunning === false) workerRunning = setTimeout(() => {
workerRunning = true // Convert new CloudFile entries to BlockUpload entries
worker() while (upqueue.length) {
const file = upqueue.shift()!
const blocks = getUploadBlocks(file)
blockQueue.push({ file, blocks, blockIndex: 0 })
}
if (blockQueue.length) {
workerRunning = true
worker()
} else {
workerRunning = false
}
}, 0) }, 0)
} }
+124 -103
View File
@@ -4,48 +4,68 @@
<div v-else> <div v-else>
<h3>Server Settings</h3> <h3>Server Settings</h3>
<div class="form-row"> <div class="form-row">
<input <label for="serverName">Server name</label>
id="publicServer" <div class="input-with-hint">
type="checkbox" <input
v-model="serverSettings.public" type="text"
@change="updateServerSettings" id="serverName"
/> v-model="serverSettings.name"
<label for="publicServer">Publicly accessible without any user account.</label> @input="debouncedUpdateServerName"
:placeholder="store.server.name"
/>
<small>Leave empty to use the share folder name</small>
</div>
</div> </div>
<h3>Users</h3> <div class="form-row">
<button @click="addUser" class="button" title="Add new user"> Add User</button> <label for="publicAccess">
<div v-if="success" class="success-message" @click="copySuccess(false)"> <input
{{ success }} type="checkbox"
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button> id="publicAccess"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
Public access (anyone can read and write)
</label>
</div> </div>
<table class="user-table"> <template v-if="store.server.paskia">
<thead> <h3>User Management</h3>
<tr> <p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
<th>Username</th> </template>
<th>Admin</th> <template v-else>
<th>Actions</th> <h3>Users</h3>
</tr> <button @click="addUser" class="button" title="Add new user"> Add User</button>
</thead> <div v-if="success" class="success-message" @click="copySuccess(false)">
<tbody> {{ success }}
<tr v-for="user in users" :key="user.username"> <button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
<td>{{ user.username }}</td> </div>
<td> <table>
<input <thead>
type="checkbox" <tr>
:checked="user.privileged" <th>Username</th>
@change="toggleAdmin(user, $event)" <th>Admin</th>
:disabled="user.username === store.user.username" <th>Actions</th>
/> </tr>
</td> </thead>
<td> <tbody>
<button @click="renameUser(user)" class="button small" title="Rename user"></button> <tr v-for="user in users" :key="user.username">
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button> <td>{{ user.username }}</td>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button> <td>
</td> <input
</tr> type="checkbox"
</tbody> :checked="user.privileged"
</table> @change="toggleAdmin(user, $event)"
<h3 class="error-text">{{ error || '\u00A0' }}</h3> :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"> <div class="dialog-buttons">
<button @click="close" class="button">Close</button> <button @click="close" class="button">Close</button>
</div> </div>
@@ -55,7 +75,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User' import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
@@ -68,16 +88,17 @@ interface User {
const store = useMainStore() const store = useMainStore()
const loading = ref(true) const loading = ref(true)
const users = ref<User[]>([]) const users = ref<User[]>([])
const error = ref('')
const success = ref('') const success = ref('')
const copyButtonText = ref('📋') const copyButtonText = ref('📋')
const serverSettings = reactive({ const serverSettings = reactive({
public: false public: false,
name: '',
}) })
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
const close = () => { const close = () => {
store.dialog = '' store.dialog = ''
error.value = ''
success.value = '' success.value = ''
} }
@@ -88,7 +109,7 @@ const loadUsers = async () => {
users.value = data.users users.value = data.users
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to load users' store.showToast(httpError.message || 'Failed to load users')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -98,7 +119,6 @@ const addUser = async () => {
const username = window.prompt('Enter username for new user:') const username = window.prompt('Enter username for new user:')
if (!username || !username.trim()) return if (!username || !username.trim()) return
try { try {
error.value = ''
success.value = '' success.value = ''
const result = await createUser(username.trim(), undefined, false) const result = await createUser(username.trim(), undefined, false)
await loadUsers() await loadUsers()
@@ -107,19 +127,18 @@ const addUser = async () => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to add user' store.showToast(httpError.message || 'Failed to add user')
} }
} }
const toggleAdmin = async (user: User, event: Event) => { const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
try { try {
error.value = ''
await updateUser(user.username, { privileged: target.checked }) await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked user.privileged = target.checked
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update user' store.showToast(httpError.message || 'Failed to update user')
target.checked = user.privileged // revert target.checked = user.privileged // revert
} }
} }
@@ -130,7 +149,6 @@ const renameUser = async (user: User) => {
// For rename, we need to create new user and delete old, or have a rename endpoint // For rename, we need to create new user and delete old, or have a rename endpoint
// Since no rename endpoint, perhaps delete and create // Since no rename endpoint, perhaps delete and create
try { try {
error.value = ''
success.value = '' success.value = ''
const result = await createUser(newName.trim(), undefined, user.privileged) const result = await createUser(newName.trim(), undefined, user.privileged)
await deleteUser(user.username) await deleteUser(user.username)
@@ -140,14 +158,13 @@ const renameUser = async (user: User) => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to rename user' store.showToast(httpError.message || 'Failed to rename user')
} }
} }
const resetPassword = async (user: User) => { const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
try { try {
error.value = ''
success.value = '' success.value = ''
const result = await updateUser(user.username, { password: "" }) const result = await updateUser(user.username, { password: "" })
if (result.password) { if (result.password) {
@@ -155,26 +172,25 @@ const resetPassword = async (user: User) => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to reset password' store.showToast(httpError.message || 'Failed to reset password')
} }
} }
const deleteUserAction = async (username: string) => { const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return if (!confirm(`Delete user ${username}?`)) return
try { try {
error.value = ''
await deleteUser(username) await deleteUser(username)
await loadUsers() await loadUsers()
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to delete user' store.showToast(httpError.message || 'Failed to delete user')
} }
} }
const copySuccess = async (isButtonClick: boolean = false) => { const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/) const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
if (passwordMatch) { if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1]) await navigator.clipboard.writeText(passwordMatch[1]!)
if (isButtonClick) { if (isButtonClick) {
// Show "Copied!" indication on button // Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!' copyButtonText.value = '✅ Copied!'
@@ -195,7 +211,6 @@ const copySuccess = async (isButtonClick: boolean = false) => {
const updateServerSettings = async () => { const updateServerSettings = async () => {
try { try {
error.value = ''
success.value = '' success.value = ''
await updatePublic(serverSettings.public) await updatePublic(serverSettings.public)
// Update store // Update store
@@ -203,63 +218,69 @@ const updateServerSettings = async () => {
success.value = 'Server settings updated' success.value = 'Server settings updated'
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update settings' store.showToast(httpError.message || 'Failed to update settings')
}
}
const updateServerNameSetting = async () => {
try {
const result = await updateServerName(serverSettings.name)
// Update store with the effective name returned by the server
store.server.name = result.name
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to update server name')
}
}
const debouncedUpdateServerName = () => {
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
}
// Load server config from admin API
const loadServerConfig = async () => {
try {
const config = await getServerConfig()
serverSettings.name = config.name
serverSettings.public = config.public
} catch (e) {
// Fallback to store values if API fails
serverSettings.public = store.server.public || false
serverSettings.name = ''
} }
} }
onMounted(() => { onMounted(() => {
serverSettings.public = store.server.public serverSettings.public = store.server.public || false
loadUsers() serverSettings.name = ''
loading.value = false
})
// Load users and config when dialog opens
watch(() => store.dialog, (newVal) => {
if (newVal === 'usermgmt') {
loadServerConfig()
if (!store.server.paskia) {
loadUsers()
}
}
}) })
watch(() => store.server.public, (newVal) => { watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal serverSettings.public = newVal || false
}) })
</script> </script>
<style scoped> <style scoped>
.user-table { /* Component-specific styles - most styling comes from ModalDialog.vue global styles */
width: 100%; .input-with-hint {
border-collapse: collapse;
margin-top: 1rem;
}
.user-table th, .user-table td {
border: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
.user-table th {
background: var(--soft-color);
}
.button.small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
margin-right: 0.25rem;
}
.button.danger {
background: var(--red-color);
color: white;
}
.button.danger:hover {
background: #d00;
}
.form-row {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 1rem; gap: 0.25rem;
margin-bottom: 0.5rem;
} }
.form-row label { .input-with-hint small {
min-width: 100px; color: #666;
font-size: 0.75rem;
} }
.success-message { </style>
background: var(--accent-color);
color: white;
padding: 0.5rem;
border-radius: 0.25rem;
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
+49 -48
View File
@@ -1,71 +1,71 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// Type for API error responses
interface ApiError {
error: {
code: number
message: string
}
}
function hasError(msg: unknown): msg is ApiError {
return typeof msg === 'object' && msg !== null && 'error' in msg
}
class ClientClass { class ClientClass {
async get(url: string): Promise<any> { async get(url: string): Promise<any> {
const res = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json'
}
})
let msg
try { try {
msg = await res.json() const msg = await apiJson(url, { method: 'GET' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) { } catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`) if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
} }
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} }
async post(url: string, data?: Record<string, any>): Promise<any> { async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try { try {
msg = await res.json() const msg = await apiJson(url, {
method: 'POST',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) { } catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`) if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
} }
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} }
async put(url: string, data?: Record<string, any>): Promise<any> { async put(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try { try {
msg = await res.json() const msg = await apiJson(url, {
method: 'PUT',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) { } catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`) if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
} }
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} }
async delete(url: string): Promise<any> { async delete(url: string): Promise<any> {
const res = await fetch(url, {
method: 'DELETE',
headers: {
accept: 'application/json'
}
})
let msg
try { try {
msg = await res.json() const msg = await apiJson(url, { method: 'DELETE' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) { } catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`) if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
} }
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} }
} }
@@ -82,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
} }
} }
export { apiFetch }
export default Client export default Client
+31 -8
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate, haystackFormat } from "@/utils" import { formatSize, formatUnixDate } from "@/utils"
export type FUID = string export type FUID = string
@@ -7,27 +7,45 @@ export type DocProps = {
name: string name: string
key: FUID key: FUID
size: number size: number
allocated: number
mtime: number mtime: number
dir: boolean dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
} }
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 allocated: number = 0
public mtime: number = 0 public mtime: number = 0
public haystack: string = ""
public dir: boolean = false public dir: boolean = false
public ghost: boolean = false
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */
public _name: string = ""
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) } constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props
Object.assign(this, rest)
if (name) this._name = name // Skip validation/haystack for bulk loading
}
get name() { return this._name } get name() { return this._name }
set name(name: string) { set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name this._name = name
this.haystack = haystackFormat(name)
} }
get sizedisp(): string { return formatSize(this.size) } get sizedisp(): string { return formatSize(this.size) }
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
get sparseIndicator(): string {
if (this.dir || this.size <= this.allocated) return ''
if (this.allocated === 0) return '⭕' // exactly zero
const ratio = this.allocated / this.size
// Round to nearest 25%: ◔◑◕⬤
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
}
get modified(): string { return formatUnixDate(this.mtime) } get modified(): string { return formatUnixDate(this.mtime) }
get url(): string { get url(): string {
const p = this.loc ? `${this.loc}/${this.name}` : this.name const p = this.loc ? `${this.loc}/${this.name}` : this.name
@@ -41,6 +59,9 @@ export class Doc {
if (this.dir) return false if (this.dir) return false
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext) return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
} }
get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated)
}
get previewable(): boolean { get previewable(): boolean {
// Folders cannot be previewable // Folders cannot be previewable
if (this.dir) return false if (this.dir) return false
@@ -49,6 +70,7 @@ export class Doc {
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext) return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
} }
get previewurl(): string { get previewurl(): string {
if (!this.complete || !this.previewable) return ''
return this.url.replace(/^\/files/, '/preview') return this.url.replace(/^\/files/, '/preview')
} }
get ext(): string { get ext(): string {
@@ -71,9 +93,10 @@ export type FileEntry = [
number, // level number, // level
string, // name string, // name
FUID, FUID,
number, //mtime number, // mtime
number, // size number, // size
number, // isfile number, // allocated (actual disk usage)
number, // isfile
] ]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+16 -6
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, {
@@ -25,7 +25,7 @@ export async function changePassword(username: string, passwordChange: string, p
return data return data
} }
export const url_users = '/users' export const url_users = '/auth/users'
export async function listUsers() { export async function listUsers() {
const data = await Client.get(url_users) const data = await Client.get(url_users)
@@ -51,7 +51,17 @@ export async function deleteUser(username: string) {
return data return data
} }
export async function updatePublic(publicFlag: boolean) { export async function updatePublic(isPublic: boolean) {
const data = await Client.put('/config/public', { public: publicFlag }) const data = await Client.put('/api/config/public', { public: isPublic })
return data return data
} }
export async function updateServerName(name: string) {
const data = await Client.put('/api/config/name', { name })
return data
}
export async function getServerConfig() {
const data = await Client.get('/api/config')
return data as { name: string, public: boolean }
}
+69 -13
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)
@@ -109,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
handleUpdateMessage(msg) handleUpdateMessage(msg)
break break
case !!msg.space: case !!msg.space:
console.log('Watch space', msg.space) const store = useMainStore()
store.space = msg.space
break break
case !!msg.error: case !!msg.error:
handleError(msg) handleError(msg)
@@ -152,9 +208,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
} }
} }
+33
View File
@@ -0,0 +1,33 @@
// Non-reactive document storage for the full file list
// This avoids Vue reactivity overhead on large arrays
import type { Doc } from '@/repositories/Document'
import { shallowRef, triggerRef } from 'vue'
// The main document list - shallowRef means only the reference is reactive, not the contents
const documents = shallowRef<Doc[]>([])
// Version counter for manual reactivity triggering
let version = 0
export function getDocuments(): Doc[] {
return documents.value
}
export function setDocuments(docs: Doc[]): void {
documents.value = docs
version++
}
export function getVersion(): number {
return version
}
// Trigger reactivity manually (e.g., after modifications)
export function triggerUpdate(): void {
version++
triggerRef(documents)
}
// For computed dependencies that need to react to document changes
export const documentRef = documents
+283 -33
View File
@@ -2,38 +2,108 @@ import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia' import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils' import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User' import { watchConnect, resumeWatching } from '@/repositories/WS'
import { watchConnect } from '@/repositories/WS'
import { shallowRef } from 'vue'
import { sorted, type SortOrder } from '@/utils/docsort' import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
import { getDocuments, setDocuments, documentRef } from './documentStore'
export const useMainStore = defineStore({ // Singleton search worker instance
id: 'main', let searchWorker: Worker | null = null
let searchId = 0
let searchStore: ReturnType<typeof useMainStore> | null = null
let loadingTimer: ReturnType<typeof setTimeout> | null = null
let clearOldResultsTimer: ReturnType<typeof setTimeout> | null = null
let lastResultUpdate = 0
function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new SearchWorker()
// Set up message handler once
searchWorker.onmessage = (e) => {
if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances
const docs = e.data.docs.map((d: any) => new Doc(d))
// Cancel the clear-old-results timer since we have new results
if (clearOldResultsTimer) {
clearTimeout(clearOldResultsTimer)
clearOldResultsTimer = null
}
// Throttle rapid intermediate updates to reduce UI flicker
const now = performance.now()
if (!e.data.done && now - lastResultUpdate < 50) {
return // Skip intermediate update if too recent
}
lastResultUpdate = now
searchStore.searchResults = docs
if (e.data.done) {
// Clear the loading timer and hide spinner
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
searchStore.searchLoading = false
}
}
}
return searchWorker
}
// Ghost expiry time in seconds
const GHOST_TTL = 30
// Periodic cleanup interval
let cleanupInterval: ReturnType<typeof setInterval> | null = null
export const useMainStore = defineStore('main', {
state: () => ({ state: () => ({
document: shallowRef<Doc[]>([]), // Ghosts are temporary optimistic-update files/folders shown until server confirms
ghosts: [] as Doc[],
// Hidden paths for optimistic delete (path -> expiry timestamp)
hiddenPaths: new Map<string, number>(),
// Version counter to trigger reactivity when external document list changes
docVersion: 0,
selected: new Set<FUID>([]), selected: new Set<FUID>([]),
query: '' as string, query: '' as string,
searchResults: [] as Doc[],
searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses)
toastTimeout: null as ReturnType<typeof setTimeout> | null,
connected: false, connected: false,
authInProgress: false,
cursor: '' as string, cursor: '' as string,
server: {} as Record<string, any>, server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
dialog: '' as '' | 'login' | 'settings' | 'usermgmt', dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
prefs: { prefs: {
gallery: false, gallery: false,
sortListing: '' as SortOrder, sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder, sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key)
}, },
user: { user: {
username: '' as string, username: '' as string,
privileged: false as boolean, privileged: false as boolean,
isLoggedIn: false as boolean, isLoggedIn: false as boolean,
},
space: {
disk: 0,
free: 0,
used: 0,
storage: 0,
allocated: 0,
} }
}), }),
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)
@@ -50,44 +120,217 @@ export const useMainStore = defineStore({
updateRoot(root: FileEntry[]) { updateRoot(root: FileEntry[]) {
const docs = [] const docs = []
let loc = [] as string[] let loc = [] as string[]
for (const [level, name, key, mtime, size, isfile] of root) { for (const [level, name, key, mtime, size, allocated, isfile] of root) {
loc = loc.slice(0, level - 1) loc = loc.slice(0, level - 1)
docs.push(new Doc({ docs.push(new Doc({
name, name,
loc: level ? loc.join('/') : '/', loc: level ? loc.join('/') : '/',
key, key,
size, size,
allocated,
mtime, mtime,
dir: !isfile, dir: !isfile,
})) }))
loc.push(name) loc.push(name)
} }
this.document = docs // Store in non-reactive external storage
setDocuments(docs)
// Clear ghosts that now exist in the real list
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name))
// Clear hidden paths that no longer exist (deletion confirmed)
for (const path of this.hiddenPaths.keys()) {
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
}
// Start cleanup timer if not running
this.startCleanupTimer()
// Bump version to trigger reactive updates
this.docVersion++
// Sync documents to search worker
this.syncSearchWorker()
},
/** Add a ghost file/folder for optimistic UI updates */
addGhost(doc: Doc) {
doc.ghost = true
doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL
this.ghosts.push(doc)
},
/** Clear all ghosts (e.g., on navigation or refresh) */
clearGhosts() {
this.ghosts = []
},
/** Hide a document path (optimistic delete) */
hideDoc(path: string) {
this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL)
},
/** Unhide a document path (delete failed, restore visibility) */
unhideDoc(path: string) {
this.hiddenPaths.delete(path)
},
/** Start the periodic cleanup timer */
startCleanupTimer() {
if (cleanupInterval) return
cleanupInterval = setInterval(() => this.cleanupExpired(), 5000)
},
/** Stop the cleanup timer */
stopCleanupTimer() {
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
}
},
/** Remove expired ghosts and hidden paths */
cleanupExpired() {
const now = Math.floor(Date.now() / 1000)
const ghostsBefore = this.ghosts.length
const hiddenBefore = this.hiddenPaths.size
this.ghosts = this.ghosts.filter(g => g.expires > now)
for (const [path, expires] of this.hiddenPaths) {
if (expires <= now) this.hiddenPaths.delete(path)
}
// Stop timer if nothing to clean up
if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) {
this.stopCleanupTimer()
}
},
/** Show a temporary toast message that auto-dismisses */
showToast(message: string, duration = 3000) {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout)
this.toastTimeout = null
}
this.toast = message
this.toastTimeout = setTimeout(() => {
this.toast = ''
this.toastTimeout = null
}, duration)
},
/** Clear the current toast immediately */
clearToast() {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout)
this.toastTimeout = null
}
this.toast = ''
},
syncSearchWorker() {
const worker = getSearchWorker()
// Send plain data to worker (no class instances)
const docs = getDocuments()
const docData = docs.map(doc => ({
loc: doc.loc,
name: doc.name,
key: doc.key,
size: doc.size,
allocated: doc.allocated,
mtime: doc.mtime,
dir: doc.dir,
}))
worker.postMessage({ type: 'update', documents: docData })
},
search(query: string, loc: string) {
const worker = getSearchWorker()
const id = ++searchId
searchStore = this // Store reference for worker callback
// Update query immediately so watchers know we're handling this
this.query = query
// Cancel pending timers
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
if (clearOldResultsTimer) {
clearTimeout(clearOldResultsTimer)
clearOldResultsTimer = null
}
if (!query) {
// Clear results only when search is closed
this.searchResults = []
this.searchLoading = false
return
}
// Keep old results briefly to avoid flicker on fast cached searches
// But clear them after 50ms if no new results have arrived
clearOldResultsTimer = setTimeout(() => {
if (searchId === id) {
this.searchResults = []
}
clearOldResultsTimer = null
}, 50)
// Delay showing loading indicator to avoid flicker on fast searches
loadingTimer = setTimeout(() => {
if (searchId === id) { // Still the current search
this.searchLoading = true
}
loadingTimer = null
}, 100)
worker.postMessage({ type: 'search', query, loc, id })
}, },
login(username: string, privileged: boolean) { login(username: string, privileged: boolean) {
this.user.username = username this.user.username = username
this.user.privileged = privileged this.user.privileged = privileged
this.user.isLoggedIn = true this.user.isLoggedIn = true
this.dialog = '' this.dialog = ''
if (!this.connected) watchConnect() if (!this.connected) resumeWatching()
}, },
loginDialog() { clearSensitiveData() {
this.dialog = 'login' // Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files')
setDocuments([])
this.ghosts = []
this.hiddenPaths.clear()
this.stopCleanupTimer()
this.docVersion++
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 const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name const newOrder = current === name ? '' : name
if (this.query) this.prefs.sortFiltered = newOrder
else this.prefs.sortListing = newOrder
this.showSortToast(newOrder)
}, },
sort(name: SortOrder | '') { sort(name: SortOrder | '') {
if (this.query) this.prefs.sortFiltered = name if (this.query) this.prefs.sortFiltered = name
else this.prefs.sortListing = name else this.prefs.sortListing = name
this.showSortToast(name)
},
showSortToast(order: SortOrder | '') {
const labels: Record<string, string> = {
'': 'Folders first',
'name': 'Alphabetical order',
'modified': 'Newest first',
'size': 'Largest first',
}
this.showToast(labels[order] || order, 1200)
}, },
focusBreadcrumb() { focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus() (document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
@@ -102,8 +345,21 @@ export const useMainStore = defineStore({
getters: { getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
isUserLogged(): boolean { return this.user.isLoggedIn }, isUserLogged(): boolean { return this.user.isLoggedIn },
recentDocuments(): Doc[] { return sorted(this.document, 'modified') }, /** Get documents count (triggers on docVersion change) */
documentCount(): number {
// Access docVersion to make this reactive
void this.docVersion
return getDocuments().length
},
recentDocuments(): Doc[] {
// Access docVersion to make this reactive
void this.docVersion
return sorted(getDocuments(), 'modified')
},
selectedFiles(): SelectedItems { selectedFiles(): SelectedItems {
// Access docVersion to make this reactive
void this.docVersion
const docs = getDocuments()
const selected = this.selected const selected = this.selected
const found = new Set<FUID>() const found = new Set<FUID>()
const ret: SelectedItems = { const ret: SelectedItems = {
@@ -112,7 +368,7 @@ export const useMainStore = defineStore({
keys: [], keys: [],
recursive: [], recursive: [],
} }
for (const doc of this.document) { for (const doc of docs) {
if (selected.has(doc.key)) { if (selected.has(doc.key)) {
found.add(doc.key) found.add(doc.key)
ret.keys.push(doc.key) ret.keys.push(doc.key)
@@ -122,22 +378,16 @@ export const useMainStore = defineStore({
// What did we not select? // What did we not select?
for (const key of selected) if (!found.has(key)) ret.missing.add(key) for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively // Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Doc) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel)
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) ret.recursive.push([base.name, basepath, base])
for (const doc of this.document) { for (const doc of docs) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove) const rel = full.slice(nremove)
add(rel, full, doc) ret.recursive.push([rel, full, doc])
} }
} }
} }
+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
}
+45 -1
View File
@@ -1,8 +1,52 @@
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
export const exists = (path: string[]) => { export const exists = (path: string[]) => {
const store = useMainStore() const store = useMainStore()
// Access docVersion to make this reactive
void store.docVersion
const p = path.join('/') const p = path.join('/')
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p) return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
}
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
export const stripExt = (name: string): string => {
// Common compound extensions
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
const lower = name.toLowerCase()
for (const ext of compoundExts) {
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
}
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
const lastDot = name.lastIndexOf('.')
if (lastDot > 0) {
const ext = name.slice(lastDot + 1)
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
return name.slice(0, lastDot)
}
}
return name
}
/** Generate a sensible zip filename for a selection of items */
export const zipName = (items: { name: string; loc: string }[]): string => {
const names = items.map(d => d.name)
if (names.length === 1) {
// Single item - use its name
return stripExt(names[0]!)
}
// Check if all items share the same direct parent folder
const locs = items.map(d => d.loc)
const sameLoc = locs.every(loc => loc === locs[0])
if (sameLoc && locs[0]) {
// All items in same folder - use folder name
return locs[0].split('/').pop()!
}
if (names.length <= 3) {
// Few items from different folders - join basenames with dot
return names.map(stripExt).join('.')
}
// Many items from different folders - first basename + indicator
return `${stripExt(names[0]!)}.etc`
} }
+1 -1
View File
@@ -70,7 +70,7 @@ export function getFileType(name: string): string {
const dotIndex = name.lastIndexOf('.') const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown' if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
const ext = name.slice(dotIndex + 1).toLowerCase() const ext = name.slice(dotIndex + 1).toLowerCase()
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown' return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
} }
// Prebuilt for fast & consistent sorting // Prebuilt for fast & consistent sorting
+58 -37
View File
@@ -2,26 +2,27 @@
<Gallery <Gallery
v-if="store.prefs.gallery" v-if="store.prefs.gallery"
ref="fileExplorer" ref="fileExplorer"
:key="`gallery-${Router.currentRoute.value.path}`" :key="`gallery-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<FileExplorer <FileExplorer
v-else v-else
ref="fileExplorer" ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`" :key="`explorer-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path /> <EmptyFolder :documents=documents :path=props.path />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue' import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import { getDocuments } from '@/stores/documentStore'
import { needleFormat, localeIncludes, collator } from '@/utils' import { collator } from '@/utils'
import { sorted } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
const store = useMainStore() const store = useMainStore()
@@ -30,50 +31,59 @@ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
}>() }>()
// Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
// Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing)
watch(
() => [props.query, props.path.join('/')] as const,
([query, loc]) => {
if (store.query === query) return // Already searching this query
store.search(query, loc)
},
{ immediate: true }
)
const documents = computed(() => { const documents = computed(() => {
const loc = props.path.join('/') const loc = props.path.join('/')
const query = props.query const query = props.query
// List the current location
if (!query) return sorted( // List the current location (no search)
store.document.filter(doc => doc.loc === loc), if (!query) {
store.prefs.sortListing, // Access docVersion to make this reactive to document changes
) void store.docVersion
// Find up to 100 newest documents that match the search const hidden = store.hiddenPaths
const needle = needleFormat(query) const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
let limit = 100 // Overlay ghosts for this location (excluding hidden ones)
let docs = [] const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
for (const doc of store.recentDocuments) { // Merge: ghosts that don't conflict with real docs
if (localeIncludes(doc.haystack, needle)) { const realNames = new Set(docs.map(d => d.name))
docs.push(doc) const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
if (--limit === 0) break return sorted(merged, store.prefs.sortListing)
}
} }
const locsub = loc + '/'
// Custom sort override in effect? // Search results from worker (also filter hidden)
const hidden = store.hiddenPaths
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
// 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
docs.sort((a, b) => ( // Results are already sorted by relevance in the worker
// @ts-ignore
(b.loc === loc) - (a.loc === loc) ||
// @ts-ignore
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
collator.compare(a.loc, b.loc) ||
// @ts-ignore
(a.type === 'file') - (b.type === 'file') ||
// @ts-ignore
b.name.includes(query) - a.name.includes(query) ||
collator.compare(a.name, b.name)
))
return docs return docs
}) })
watchEffect(() => { watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
store.query = props.query
}) })
watch([() => props.path.join('/'), () => props.query], () => { // Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.documentCount], ([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) store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -89,4 +99,15 @@ watch([() => props.path.join('/'), () => props.query], () => {
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008; text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color); color: var(--accent-color);
} }
.search-loading {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
background: var(--accent-color, #007bff);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
opacity: 0.9;
}
</style> </style>
+226
View File
@@ -0,0 +1,226 @@
// Search worker - runs search in background thread
// Receives document updates and search queries, returns incremental results
interface DocData {
loc: string
name: string
key: string
size: number
allocated: number
mtime: number
dir: boolean
}
interface WorkerDoc extends DocData {
haystack: string
}
interface SearchMessage {
type: 'search'
query: string
loc: string
id: number
}
interface UpdateMessage {
type: 'update'
documents: DocData[]
}
type IncomingMessage = SearchMessage | UpdateMessage
interface ResultMessage {
type: 'results'
docs: DocData[]
id: number
done: boolean
}
// Worker state
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0
// Search result cache - cleared when documents change
interface CacheEntry {
query: string // Normalized query string
results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents
}
const searchCache: CacheEntry[] = []
const MAX_CACHE_SIZE = 10
const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string {
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
}
function normalizeQuery(str: string): string {
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
// Test if document matches search query
function matches(haystack: string, query: string, words: string[]): boolean {
return haystack.includes(query) || words.every(word => haystack.includes(word))
}
// Collator for sorting
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
// Yield control to allow new messages to be processed
const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
// Find best cache entry to filter from
// Returns entry if new query's results are guaranteed to be a subset of cached results
// Only valid if the cached search was complete (scanned all documents)
function findCacheSubset(query: string): CacheEntry | null {
// Look for a cached query that the new query starts with
// e.g., cached "foo" can be used for "foobar" or "foo bar"
// The longer the prefix, the better (fewer items to filter)
// IMPORTANT: Only use complete cache entries - incomplete ones may have
// missed results that would match the more specific query
let best: CacheEntry | null = null
for (const entry of searchCache) {
if (entry.complete && query.startsWith(entry.query)) {
if (!best || entry.query.length > best.query.length) {
best = entry
}
}
}
return best
}
// Add result to cache
function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
// Remove existing entry for same query if any
const idx = searchCache.findIndex(e => e.query === query)
if (idx !== -1) searchCache.splice(idx, 1)
// Add to front (most recent)
searchCache.unshift({ query, results, complete })
// Trim cache
if (searchCache.length > MAX_CACHE_SIZE) searchCache.pop()
}
// Clear cache (called when documents change)
function clearCache() {
searchCache.length = 0
}
// Perform search with incremental results
async function performSearch(rawQuery: string, loc: string, searchId: number) {
const query = normalizeQuery(rawQuery)
const words = query.split(/\s+/)
const results: WorkerDoc[] = []
let lastResultCount = 0
// Check cache for exact match
const exactMatch = searchCache.find(e => e.query === query)
if (exactMatch) {
if (currentSearchId === searchId) {
postResults(exactMatch.results, rawQuery, loc, searchId, true)
}
return
}
// Check if we can filter from a cached superset
const cacheEntry = findCacheSubset(query)
if (cacheEntry) {
// Fast path: filter from cached results (only used for complete cache entries)
for (const doc of cacheEntry.results) {
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Cache entry was complete, so filtered results are also complete
addToCache(query, results, true)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
return
}
// Slow path: scan all documents
const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
if (currentSearchId !== searchId) return // Superseded
// Process batch
const end = Math.min(i + batchSize, recentDocuments.length)
for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
const doc = recentDocuments[j]!
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Post incremental results if we found new matches
if (results.length > lastResultCount && currentSearchId === searchId) {
lastResultCount = results.length
postResults(results, rawQuery, loc, searchId, false)
}
// Yield control between batches
if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
await yieldControl()
}
}
// Cache and post final results
addToCache(query, results, results.length < RESULT_LIMIT)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
}
// Post results to main thread
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
const sorted = sortResults(docs, query, loc)
postMessage({
type: 'results',
docs: sorted.map(({ haystack, ...rest }) => rest),
id,
done
} as ResultMessage)
}
// Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/'
return [...docs].sort((a, b) => (
// Current folder first
Number(b.loc === loc) - Number(a.loc === loc) ||
// Then subfolders
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
// Then by location
collator.compare(a.loc, b.loc) ||
// Folders before files
Number(b.dir) - Number(a.dir) ||
// Exact name match first
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
// Finally by name
collator.compare(a.name, b.name)
))
}
// Handle incoming messages
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
const msg = e.data
if (msg.type === 'update') {
// Update document list with haystacks, sorted by mtime descending
recentDocuments = msg.documents
.map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
.sort((a, b) => b.mtime - a.mtime)
clearCache()
} else if (msg.type === 'search') {
currentSearchId = msg.id
if (msg.query) {
await performSearch(msg.query, msg.loc, msg.id)
} else {
// Empty query - no results needed
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
}
}
}
+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:8999 - Backend API URL for proxying
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
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 -23
View File
@@ -1,4 +1,5 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
@@ -7,18 +8,11 @@ import vue from '@vitejs/plugin-vue'
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
// Development mode:
// bun 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(),
svgLoader(), // import svg files svgLoader(), // import svg files
Components(), // auto import components Components(), // auto import components
@@ -36,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',
],
},
},
},
},
}) })
+15 -9
View File
@@ -27,7 +27,11 @@ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"docopt>=0.6.2", "docopt-ng>=0.9.0",
"fastapi-vue>=0.5.2",
"fastapi[standard]>=0.128.0",
"html5tagger>=1.3.0",
"httpx>=0.28.0",
"inotify>=0.2.12", "inotify>=0.2.12",
"msgspec>=0.19.0", "msgspec>=0.19.0",
"natsort>=8.4.0", "natsort>=8.4.0",
@@ -37,10 +41,12 @@ dependencies = [
"pillow-heif>=1.1.0", "pillow-heif>=1.1.0",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"pymupdf>=1.26.3", "pymupdf>=1.26.3",
"sanic>=25.3.0", "pyvips[binary]>=3.1.1",
"sanic>=25.12.0",
"setproctitle>=1.3.6", "setproctitle>=1.3.6",
"stream-zip>=0.0.83", "stream-zip>=0.0.83",
"tomli_w>=1.2.0", "tomli_w>=1.2.0",
"tracerite>=2.3.1",
"zstandard>=0.24.0", "zstandard>=0.24.0",
] ]
@@ -71,8 +77,8 @@ docs = [
source = "vcs" source = "vcs"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["cista/wwwroot"] artifacts = ["cista/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
targets.sdist.include = [ targets.sdist.include = [
"/cista", "/cista",
] ]
@@ -82,6 +88,7 @@ hooks.vcs.template = """
__version__ = {version!r} __version__ = {version!r}
""" """
only-packages = true only-packages = true
packages = ["cista"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = [ addopts = [
@@ -109,6 +116,7 @@ filterwarnings = [
] ]
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["E402"]
isort.known-first-party = ["cista"] isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"] per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
per-file-ignores."scripts/*" = ["T20"] per-file-ignores."scripts/*" = ["T20"]
@@ -116,14 +124,12 @@ per-file-ignores."scripts/*" = ["T20"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.4.1", "pytest>=8.4.1",
"pytest-asyncio>=0.25.0",
"pytest-cov>=7.0.0",
"ruff>=0.8.0", "ruff>=0.8.0",
"mypy>=1.13.0", "mypy>=1.13.0",
"pre-commit>=4.0.0", "pre-commit>=4.0.0",
] "httpx>=0.28.1",
test = [
"pytest>=8.4.1",
"pytest-cov>=6.0.0",
"pytest-asyncio>=0.25.0",
] ]
[tool.coverage.run] [tool.coverage.run]
-37
View File
@@ -1,37 +0,0 @@
# noqa: INP001
import os
import shutil
import subprocess
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building Cista frontend\n")
npm = None
bun = shutil.which("bun")
if bun is None:
npm = shutil.which("npm")
if npm is None:
raise RuntimeError(
"Bun or NodeJS `npm` is required for building but neither was found\n Visit https://bun.com/"
)
# npm --prefix doesn't work on Windows, so we chdir instead
os.chdir("frontend")
try:
if npm:
stderr.write("### npm install\n")
subprocess.run([npm, "install"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
else:
assert bun
stderr.write("### bun install\n")
subprocess.run([bun, "install"], check=True) # noqa: S603
stderr.write("\n### bun run build\n")
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
finally:
os.chdir("..")
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [-l listen] [--backend backend] [cista_args...]
Options:
-l, --listen Vite frontend endpoint (default: localhost:8989)
--backend Cista backend endpoint (default: from config, or :8999)
Any additional arguments are passed to the cista command.
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 = 8999
def setup_sanic_backend(
listen: str | None, extra_args: list[str]
) -> 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] + extra_args
return f"http://{host}:{port}", cmd
async def run_devserver(
frontend: str | None, backend: str | None, extra_args: list[str]
) -> 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, extra_args)
# 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(
"-l",
"--listen",
metavar="host:port",
help="Vite frontend endpoint (default: localhost:8989)",
)
parser.add_argument(
"--backend",
metavar="host:port",
help="Cista backend endpoint (default: from config, or :8999)",
)
args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.listen, args.backend, unknown))
HELP_EPILOG = """
scripts/devserver.py # Default ports
scripts/devserver.py -l 3000 # Vite on localhost:3000
scripts/devserver.py -l :3000 --backend 8080 # Vite on *:3000, backend on :8080
Additional arguments are passed to the cista backend command.
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", "-A", "npm:vite"),
"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 = 8989
DEFAULT_BACKEND_PORT = 8999
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
+102
View File
@@ -0,0 +1,102 @@
import argparse
import mimetypes
from pathlib import Path
from cista.preview import process_image_with_timing
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate image previews for all files in a folder, one at a time.",
)
parser.add_argument("folder", type=Path, help="Folder to scan recursively")
parser.add_argument(
"--px",
type=int,
default=1024,
help="Maximum preview dimension in pixels (default: 1024)",
)
parser.add_argument(
"--quality",
type=int,
default=60,
help="AVIF quality passed to preview generation (default: 60)",
)
return parser.parse_args()
def is_image_file(path: Path) -> bool:
mime_type, _ = mimetypes.guess_type(path.name)
return bool(mime_type and mime_type.startswith("image/"))
def main() -> int:
args = parse_args()
folder = args.folder.resolve()
if not folder.is_dir():
raise SystemExit(f"Not a directory: {folder}")
files = sorted(
path for path in folder.rglob("*") if path.is_file() and is_image_file(path)
)
if not files:
print(f"No image files found under {folder}")
return 0
total_files = 0
total_bytes = 0
total_timing_slots: list[float] = []
total_preview_ms: float = 0.0
failures = 0
print(f"Scanning {folder}")
print(f"Generating previews for {len(files)} image files")
for path in files:
total_files += 1
rel = path.relative_to(folder)
try:
preview, timing = process_image_with_timing(
path,
maxsize=args.px,
quality=args.quality,
)
except Exception as exc:
failures += 1
print(f"FAIL {rel} error={exc}")
continue
total_bytes += len(preview)
timings = timing.timings or []
if len(total_timing_slots) < len(timings):
total_timing_slots.extend([0.0] * (len(timings) - len(total_timing_slots)))
for i, value in enumerate(timings):
total_timing_slots[i] += value
total_ms = sum(timings)
total_preview_ms += total_ms
detail = " / ".join(f"{value:.1f}ms" for value in timings)
if detail:
detail = f"timings={detail} total={total_ms:.1f}ms"
else:
detail = f"total={total_ms:.1f}ms"
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
completed = total_files - failures
print()
print("Summary")
print(f" files={total_files}")
print(f" completed={completed}")
print(f" failed={failures}")
print(f" preview_bytes={total_bytes}")
if completed:
if total_timing_slots:
for i, value in enumerate(total_timing_slots, start=1):
print(f" timing{i}_total_ms={value:.1f}")
print(f" preview_total_ms={total_preview_ms:.1f}")
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
return 0 if failures == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+5 -3
View File
@@ -10,7 +10,9 @@ def decode(data: str):
# Helper function to create a list of FileEntry objects # Helper function to create a list of FileEntry objects
def f(count, start=0): def f(count, start=0):
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)] return [
FileEntry(i, str(i), str(i), 0, 0, 0, 0) for i in range(start, start + count)
]
def test_identical_lists(): def test_identical_lists():
@@ -35,8 +37,8 @@ def test_insertions():
def test_insertion_at_end(): def test_insertion_at_end():
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)] old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1) newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
new_list = [*old_list, newfile] new_list = [*old_list, newfile]
expected = [UpdKeep(4), UpdIns([newfile])] expected = [UpdKeep(4), UpdIns([newfile])]
assert decode(format_update(old_list, new_list)) == expected assert decode(format_update(old_list, new_list)) == expected