Compare commits

...
83 Commits
Author SHA1 Message Date
LeoVasanko 041090cce9 preview: increase timeout to 10s, add priority queue scheduling (images > video > pdf > office) 2026-04-27 04:51:41 +00:00
LeoVasanko ec6db7b53f Add sort-order keycap hints next to search bar
Show 'Order [1] [2] [3]' keycaps to the right of the search bar
when the viewport is at least 800px wide. These visual hints
match the existing '/' search keycap style and correspond to
the existing keyboard shortcuts for sorting:
  1 = name (alphabetical)
  2 = modified (newest first)
  3 = size (largest first)

Hints are hidden on narrow viewports and when text input fields are focused.
2026-04-26 08:02:40 +00:00
LeoVasanko 575df1214b session: rename cookie to 'cista', add __Host- prefix on HTTPS 2026-04-27 03:56:07 +00:00
LeoVasanko 1927c24053 Revert an accidental change of functionality that was breaking file listings, originally done for linting purposes in commit 3767fb0. 2026-04-26 07:47:27 +00:00
LeoVasanko 6242c76be8 Add share token support with virtual filesystem and selection toolbar button 2026-04-26 07:46:14 +00:00
LeoVasanko 87a92838c2 Add benchmark script for OnlyOffice output formats
Benchmarks three preview pipelines across all sample documents:
- BMP → AVIF (via pyvips)
- PNG → AVIF (via pyvips) — selected for production
- PNG only (no compression)

Results confirm PNG → AVIF as the optimal path:
- ~30 ms AVIF encode overhead
- 2.1× size reduction vs raw PNG
- Slightly faster than BMP → AVIF
2026-04-26 07:28:52 +00:00
LeoVasanko 8c93a4f2b5 Add OnlyOffice-based preview for office documents
Replace Aspose.Words with OnlyOffice Document Server for generating
bitmap previews of office documents (Word, Excel, PowerPoint, etc.).

Backend:
- Add cista/onlyoffice.py conversion client
- Convert office docs directly to PNG via OnlyOffice, then AVIF via pyvips
- Make office previews optional based on OnlyOffice availability
- Remove Aspose.Words dependency and all related code
- Add spreadsheet and presentation format support

Frontend:
- Mark office files as previewable in Document.ts
- Add office extensions to MediaPreview.vue preview list
- Fix pre-existing @ts-ignore in HeaderMain.vue

Tests:
- Fix test_lrucache.py parameter name (open -> opener)

Also run ruff format across the codebase to satisfy linter checks.
2026-04-26 06:59:01 +00:00
LeoVasanko eb5ff82de6 frontend: add biome checks and pre-commit integration (excluding preview files) 2026-04-26 06:43:06 +00:00
LeoVasanko 942b54d795 lint: apply manual ruff cleanup (non-preview files) 2026-04-26 06:16:42 +00:00
LeoVasanko 18ee0f3f56 sso: use /auth/api/check for token permission checks 2026-04-26 05:46:09 +00:00
LeoVasanko f8b2c9494a WebDAV sync support, access tokens, REST control endpoints (#10)
Implement complete WebDAV file serving compatible with various clients from Windows File Explorer to more specialized sync tools. The old control WebSocket has been updated to part-DAV, part REST API instead. Implemented user:pass BASIC auth. Added UI and backend for creating tokens that avoid the need to use actual username and password for requests from CLI or DAV.
2026-04-26 04:22:52 +00:00
LeoVasanko 9ac571463a Uploads by PUT range requests rather than WS, remove dead code WS handlers (#9)
Increases block size to 16 MiB and adjusts progress display to work smoothly with that. Also removes download WS that was already unused. Provides faster upload speed than over WS.
2026-04-25 01:55:52 +00:00
LeoVasanko d52b24372a Remove CISTA_PIL and Pillow preview mode 2026-04-25 02:06:40 +00:00
LeoVasanko 742b3006c9 Adjust emoji log spacing 2026-04-25 01:59:14 +00:00
LeoVasanko 01a979c00a Improve preview worker error propagation and logging 2026-04-25 01:51:49 +00:00
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
86 changed files with 10745 additions and 1902 deletions
+1
View File
@@ -1,6 +1,7 @@
.* .*
*.lock *.lock
!.gitignore !.gitignore
!.pre-commit-config.yaml
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: local
hooks:
- id: ruff-check
name: ruff check
entry: uv run ruff check .
language: system
pass_filenames: false
- id: ruff-format-check
name: ruff format check
entry: uv run ruff format --check .
language: system
pass_filenames: false
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
- id: frontend-type-check
name: frontend type-check
entry: npm --prefix frontend run type-check
language: system
pass_filenames: false
- id: frontend-biome-check
name: frontend biome check
entry: npm --prefix frontend run check
language: system
pass_filenames: false
+55 -12
View File
@@ -8,7 +8,7 @@ This is a cutting-edge **file and document server** designed for speed, efficien
**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.
@@ -20,6 +20,11 @@ Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and
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:
Try it out locally at http://localhost:8000 (serves the current directory):
```fish
uvx cista
```
Create an account: (otherwise the server is public for all) Create an account: (otherwise the server is public for all)
```fish ```fish
uvx cista --user yourname --privileged uvx cista --user yourname --privileged
@@ -40,9 +45,13 @@ The server remembers its settings in the config folder (default `~/.local/share/
## Authentication ## Authentication
Cista supports three authentication modes: Cista supports two authentication modes, each supporting ordinary and privileged users. Either one can be combined with the public mode.
### Built-in Authentication (default) ### 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.
### Built-in Password Authentication (default)
User accounts are managed directly by Cista. Create users with the `--user` flag: User accounts are managed directly by Cista. Create users with the `--user` flag:
@@ -53,23 +62,50 @@ uvx cista --user guest # Create regular user
Privileged users can manage other users and change settings via the Admin Settings menu. Privileged users can manage other users and change settings via the Admin Settings menu.
### Public Mode ### Passkey Authentication and SSO
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off. 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.
### Paskia SSO Authentication Set the `PASKIA_BACKEND_URL` environment variable:
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
```fish ```fish
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
``` ```
Run the Paskia backend on the same machine (to use that default URL):
```fish
uvx paskia
```
In Paskia mode: In Paskia mode:
- All `/auth/*` requests are proxied to the Paskia backend - 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 - Users with `cista:login` permission can access files
- Users with `cista:admin` permission get privileged access (Admin Settings) - Users with `cista:admin` permission get privileged access (Admin Settings)
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
## WebDAV Access
Cista supports WebDAV, so you can mount it as a network drive or browse it directly from your operating system's file manager.
Connect to `https://cista.example.com/files/`.
### Authentication
- **Standard users:** Use your username and password with Basic auth.
- **API tokens:** For scripts, backup tools, or when your client requires NTLM (e.g. Windows File Explorer), create a token in the web interface via **🔑 API Tokens**. Authenticate with username `token` and the token secret as the password.
### Supported clients
| Client | Setup |
|--------|-------|
| **Windows File Explorer** | Map Network Drive → `https://cista.example.com/files/` (or Add a network location). Windows may try NTLM first; API tokens are recommended. |
| **macOS Finder** | Go → Connect to Server (⌘K) → `https://cista.example.com/files/` |
| **Linux (GNOME/KDE)** | Enter `davs://cista.example.com/files/` or `webdavs://cista.example.com/files/` in the location bar |
| **Android — Solid Explorer** | Tap **+** → New Cloud Connection → **WebDAV** → enter `https://cista.example.com/files/` and your credentials. |
| **Android — CX File Explorer** | Open the **Network** tab → **New location****WebDAV** → enter `https://cista.example.com/files/` and your credentials. |
| **Cyberduck, WinSCP, rclone** | Standard WebDAV profile with Basic auth |
**Note on Windows NTLM:** Windows WebDAV clients often require NTLM authentication, which is incompatible with Cista's Argon2 password hashes. API tokens solve this — Cista uses the token secret as the NTLM password.
### Internet Access ### Internet Access
@@ -89,9 +125,15 @@ Nxing or other proxy may be similarly used, or alternatively you can place cert
This setup allows easy addition of storages, each with its own domain, configuration, and files. This setup allows easy addition of storages, each with its own domain, configuration, and files.
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm. 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.
Create `/etc/systemd/system/cista@.service`: Create (edit) a systemd unit:
```fish
sudo systemctl edit --force --full cista@.service
```
Paste the following:
```ini ```ini
[Unit] [Unit]
@@ -101,6 +143,7 @@ Description=Cista storage %i
User=storage User=storage
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always Restart=always
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -153,4 +196,4 @@ Building the package for release (frontend + Python wheel/sdist):
uv build uv build
``` ```
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package. 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.
+1 -3
View File
@@ -1,3 +1 @@
from cista._version import __version__ from cista._version import __version__ as __version__
__version__ # Public API
+20 -13
View File
@@ -25,14 +25,19 @@ def create_banner():
""" """
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None): def create_startup_box(
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
):
"""Create a framed startup box with server information.""" """Create a framed startup box with server information."""
title = f"Cista {cista.__version__}" title = f"Cista {cista.__version__}"
listen = unix if unix else url listen = unix if unix else url
location = f"{folder} @ {listen}" location = f"{folder} @ {listen}"
lines = [title, location] lines = [title, location]
if paskia_url: # Auth line: Paskia <url> or Password, with optional Public suffix
lines.append(f"Paskia: {paskia_url}") auth_line = f"Auth: Paskia {paskia_url}" if paskia_url else "Auth: Password"
if public:
auth_line += ", Public"
lines.append(auth_line)
if dev: if dev:
lines.append("dev mode") lines.append("dev mode")
@@ -41,8 +46,7 @@ def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
# Build the box # Build the box
box = [f"{'' * inner_width}"] box = [f"{'' * inner_width}"]
for line in lines: box.extend(f"{line:<{inner_width - 1}}" for line in lines)
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}") box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n" return "\n".join(box) + "\n"
@@ -53,11 +57,13 @@ 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)
@@ -80,7 +86,7 @@ Environment:
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!
""" """
@@ -109,7 +115,7 @@ def _main():
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()
@@ -140,8 +146,8 @@ def _main():
if listen: if listen:
settings["listen"] = listen settings["listen"] = listen
elif not exists: elif not exists:
settings["listen"] = ":8000" settings["listen"] = ":8989"
operation = config.update_config(settings) config.update_config(settings)
# Prepare to serve # Prepare to serve
url, opts = serve.parse_listen(config.config.listen) url, opts = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir(): if not config.config.path.is_dir():
@@ -157,6 +163,7 @@ def _main():
unix=opts.get("unix"), unix=opts.get("unix"),
dev=dev, dev=dev,
paskia_url=PASKIA_BACKEND_URL or None, paskia_url=PASKIA_BACKEND_URL or None,
public=config.config.public,
) )
sys.stderr.write(startup_box) sys.stderr.write(startup_box)
# Run the server # Run the server
@@ -186,7 +193,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,
} }
@@ -215,7 +222,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"
) )
+73 -72
View File
@@ -1,15 +1,20 @@
import asyncio import asyncio
import typing
from secrets import token_bytes from secrets import token_bytes
import msgspec import msgspec
from sanic import Blueprint, json from sanic import Blueprint, json
from sanic.exceptions import BadRequest from sanic.exceptions import BadRequest
from sanic.log import logger
from cista import __version__, auth, config, sso, watching from cista import __version__, auth, config, sharefs, sso, watching
from cista.auth import (
create_share_token_handler,
create_token_handler,
delete_token_handler,
list_tokens_handler,
)
from cista.fileio import FileServer from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg from cista.util.apphelpers import websocket_wrapper
from cista.util.apphelpers import asend, websocket_wrapper
bp = Blueprint("api", url_prefix="/api") bp = Blueprint("api", url_prefix="/api")
fileserver = FileServer() fileserver = FileServer()
@@ -25,71 +30,6 @@ async def stop_fileserver(app):
await fileserver.stop() await fileserver.stop()
@bp.websocket("upload")
@websocket_wrapper
async def upload(req, ws):
alink = fileserver.alink
while True:
req = None
text = await ws.recv()
if not isinstance(text, str):
raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}",
)
req = msgspec.json.decode(text, type=FileRange)
pos = req.start
while True:
data = await ws.recv()
if not isinstance(data, bytes):
break
if len(data) > req.end - pos:
raise ValueError(
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
)
sentsize = await alink(("upload", req.name, pos, data, req.size))
pos += typing.cast(int, sentsize)
if pos >= req.end:
break
if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
@bp.websocket("download")
@websocket_wrapper
async def download(req, ws):
alink = fileserver.alink
while True:
req = None
text = await ws.recv()
if not isinstance(text, str):
raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}",
)
req = msgspec.json.decode(text, type=FileRange)
pos = req.start
while pos < req.end:
end = min(req.end, pos + (1 << 20))
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
await asend(ws, data)
pos += len(data)
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
@bp.websocket("control")
@websocket_wrapper
async def control(req, ws):
while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(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):
@@ -99,8 +39,8 @@ async def watch(req, ws):
# SSO auth: call validation to get user info (don't enforce auth in public mode) # SSO auth: call validation to get user info (don't enforce auth in public mode)
try: try:
await sso.validate_sso_request(req) await sso.validate_sso_request(req)
except Exception: except Exception as e:
pass # Ignore auth errors, user_info stays None logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None): if sso_user := getattr(req.ctx, "sso_user", None):
ctx = sso_user.get("ctx", {}) ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", []) perms = ctx.get("permissions", [])
@@ -129,15 +69,27 @@ async def watch(req, ws):
).decode() ).decode()
) )
uuid = token_bytes(16) uuid = token_bytes(16)
share_token = auth.request_share_token(req)
try: try:
q, space, root = await asyncio.get_event_loop().run_in_executor( q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws req.app.ctx.threadexec, subscribe, uuid, ws
) )
await ws.send(space) await ws.send(space)
if share_token is None:
await ws.send(root) await ws.send(root)
else:
await ws.send(watching.format_root(sharefs.build_virtual_root(share_token)))
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) msg = await q.get()
if share_token is None or (
isinstance(msg, str) and msg.startswith('{"space"')
):
await ws.send(msg)
else:
await ws.send(
watching.format_root(sharefs.build_virtual_root(share_token))
)
except RuntimeError as e: except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown": if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket return # Server shutting down, drop the WebSocket
@@ -157,6 +109,17 @@ def subscribe(uuid, ws):
) )
@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") @bp.put("config/public")
async def update_public(request): async def update_public(request):
await auth.verify(request, privileged=True) await auth.verify(request, privileged=True)
@@ -170,3 +133,41 @@ async def update_public(request):
raise BadRequest(str(e)) from None raise BadRequest(str(e)) from None
config.update_config({"public": public}) config.update_config({"public": public})
return json({"message": "Public access setting updated", "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})
# Token management endpoints (available in all modes; primary path in SSO mode)
@bp.get("tokens")
async def list_api_tokens(request):
return await list_tokens_handler(request)
@bp.post("tokens")
async def create_api_token(request):
return await create_token_handler(request)
@bp.delete("tokens/<token_id>")
async def delete_api_token(request, token_id):
return await delete_token_handler(request, token_id)
@bp.post("share-tokens")
async def create_share_token(request):
return await create_share_token_handler(request)
+119 -96
View File
@@ -1,78 +1,51 @@
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 pathlib import Path, PurePath, PurePosixPath from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import sanic.helpers
from blake3 import blake3 from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect from sanic import Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger from sanic.log import logger
from setproctitle import setproctitle from 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, sso, watching from cista import auth, config, fileserver, preview, session, sharefs, sso, watching
from cista.api import bp from cista.api import bp
from cista.preview import shutdown_preview_workers, start_preview_workers
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 configure_access_logging()
sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True) app = Sanic("cista", strict_slashes=True)
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL app.router.ALLOWED_METHODS = (
if sso.paskia_enabled(): *app.router.ALLOWED_METHODS,
app.blueprint(sso.bp) # SSO proxy for /auth/* routes "MKCOL",
else: "MOVE",
app.blueprint(auth.bp) # Built-in auth routes "COPY",
app.blueprint(preview.bp) "PROPFIND",
app.blueprint(bp) )
app.exception(Exception)(handle_sanic_exception)
configure_main_logging()
setproctitle("cista-main")
@app.before_server_start
async def main_start(app):
config.load_config()
setproctitle(f"cista {config.config.path.name}")
# Small pool for memory-intensive preview generation
preview_workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=preview_workers, thread_name_prefix="cista-preview"
)
# Larger pool for long-running but low-memory zip operations
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
watching.start(app)
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop
async def main_stop(app):
quit.set()
watching.stop(app)
app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client()
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.session = session.get(req) req.ctx._log_start = time.perf_counter()
try: req.ctx._auth_flow = ["session: start"]
req.ctx.username = req.ctx.session["username"] # type: ignore auth.hydrate_request_auth_context(req, source="app.on_request")
req.ctx.user = config.config.users[req.ctx.username]
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
# CSRF protection # CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket": if req.method == "GET" and req.headers.upgrade != "websocket":
return # Ordinary GET requests are fine return # Ordinary GET requests are fine
@@ -83,6 +56,29 @@ 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 @app.on_response
async def forward_sso_cookies(req, res): async def forward_sso_cookies(req, res):
"""Forward Set-Cookie headers from SSO validation to client.""" """Forward Set-Cookie headers from SSO validation to client."""
@@ -91,23 +87,54 @@ async def forward_sso_cookies(req, res):
res.headers.add("set-cookie", cookie) res.headers.add("set-cookie", cookie)
@app.on_response
async def persist_auth_session(req, res):
"""Persist a session cookie after successful Authorization-based auth."""
username = getattr(req.ctx, "_create_session_username", None)
if not username or res.status >= 400:
return
existing = getattr(req.ctx, "session", None)
if isinstance(existing, dict) and existing.get("username") == username:
return
session.create(req, res, username)
# 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(bp)
app.blueprint(fileserver.bp)
app.exception(Exception)(handle_sanic_exception)
setproctitle("cista-main")
@app.before_server_start @app.before_server_start
def http_fileserver(app): async def main_start(app):
bp = Blueprint("fileserver") config.load_config()
setproctitle(f"cista {config.config.path.name}")
@bp.on_request app.ctx.threadexec = ThreadPoolExecutor(
async def verify_fileserver(request): max_workers=4, thread_name_prefix="cista-worker"
"""Verify access to file server routes."""
await auth.verify(request)
bp.static(
"/files/",
config.config.path,
use_content_range=True,
stream_large_files=True,
directory_view=True,
) )
app.blueprint(bp) # 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)
@app.before_server_stop
async def main_stop(app):
watching.stop(app)
await shutdown_preview_workers()
app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client()
logger.debug("Cista worker threads all finished")
www = {} www = {}
@@ -174,9 +201,8 @@ def _load_wwwroot(www):
@app.before_server_start @app.before_server_start
async def start(app): async def start(app):
if not app.debug:
await load_wwwroot(app) await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
async def load_wwwroot(app): async def load_wwwroot(app):
@@ -186,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})
@@ -236,12 +240,15 @@ async def favicon(req):
return redirect("/assets/logo-ctv8tVwU.svg", status=308) return redirect("/assets/logo-ctv8tVwU.svg", status=308)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]: def get_files(req, wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath() loc = PurePosixPath()
idx = 0 idx = 0
ret = [] ret = []
level: int | None = None level: int | None = None
parent: PurePosixPath | None = None parent: PurePosixPath | None = None
token = auth.request_share_token(req)
if token is None:
with watching.state.lock: with watching.state.lock:
root = watching.state.root root = watching.state.root
while idx < len(root): while idx < len(root):
@@ -257,6 +264,21 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
idx += 1 idx += 1
return ret return ret
root = sharefs.build_virtual_root(token)
while idx < len(root):
f = root[idx]
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if parent is not None and f.level <= level:
level = parent = None
if f.key in wanted:
level, parent = f.level, loc.parent
if parent is not None:
wanted.discard(f.key)
real_path = sharefs.resolve_virtual_rel_to_real(token, loc)
ret.append((loc.relative_to(parent), real_path))
idx += 1
return ret
@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):
@@ -264,7 +286,7 @@ async def zip_download(req, keys, zipfile, ext):
await auth.verify(req) await auth.verify(req)
wanted = set(keys.split("+")) wanted = set(keys.split("+"))
files = get_files(wanted) files = get_files(req, wanted)
if not files: if not files:
raise NotFound( raise NotFound(
@@ -290,7 +312,8 @@ async def zip_download(req, keys, zipfile, ext):
while size > 0 and (chunk := f.read(min(size, 1 << 20))): while size > 0 and (chunk := f.read(min(size, 1 << 20))):
size -= len(chunk) size -= len(chunk)
yield chunk yield chunk
assert size == 0 if size != 0:
raise OSError(f"stream ended early while zipping {name}")
pending_put = None # Current queue.put future, can be cancelled pending_put = None # Current queue.put future, can be cancelled
+1051 -13
View File
File diff suppressed because it is too large Load Diff
+42 -3
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import os import os
import secrets import secrets
import sys import sys
from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
from time import sleep, time from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec from typing import Concatenate, Literal, ParamSpec
import msgspec import msgspec
import msgspec.toml import msgspec.toml
@@ -22,6 +23,7 @@ class Config(msgspec.Struct):
name: str = "" name: str = ""
users: dict[str, User] = {} users: dict[str, User] = {}
links: dict[str, Link] = {} links: dict[str, Link] = {}
tokens: dict[str, Token] = {}
# Typing: arguments for config-modifying functions # Typing: arguments for config-modifying functions
@@ -43,6 +45,17 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0 expires: int = 0
class Token(msgspec.Struct, omit_defaults=True):
key: str = "" # plain text secret (shown once on creation)
username: str = "" # set in built-in mode
sso_user_id: str = "" # set in SSO mode
name: str = ""
created: int = 0
kind: str = "api" # api | share
mode: str = "rw" # ro | rw
share_paths: list[str] = []
# Global variables - initialized during application startup # Global variables - initialized during application startup
config: Config config: Config
conffile: Path conffile: Path
@@ -63,7 +76,7 @@ def init_confdir() -> None:
conffile = home / "db.toml" conffile = home / "db.toml"
def derived_secret(*params, len=8) -> bytes: def derived_secret(*params, size=8) -> bytes:
"""Used to derive secret keys from the main secret""" """Used to derive secret keys from the main secret"""
# Each part is made the same length by hashing first # Each part is made the same length by hashing first
combined = b"".join( combined = b"".join(
@@ -71,7 +84,7 @@ def derived_secret(*params, len=8) -> bytes:
for p in [config.secret, *params] for p in [config.secret, *params]
) )
# Output a bytes of the desired length # Output a bytes of the desired length
return sha256(combined).digest()[:len] return sha256(combined).digest()[:size]
def enc_hook(obj): def enc_hook(obj):
@@ -204,3 +217,29 @@ def del_user(conf: Config, name: str) -> Config:
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"].pop(name) settings["users"].pop(name)
return msgspec.convert(settings, Config, dec_hook=dec_hook) return msgspec.convert(settings, Config, dec_hook=dec_hook)
@modifies_config
def update_token(conf: Config, token_id: str, changes: dict) -> Config:
"""Create or update a token."""
try:
t = msgspec.convert(
msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook),
Token,
dec_hook=dec_hook,
)
except KeyError:
t = Token()
tdict = msgspec.to_builtins(t, enc_hook=enc_hook)
tdict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
@modifies_config
def del_token(conf: Config, token_id: str) -> Config:
"""Delete a token by its stable id."""
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["tokens"].pop(token_id, None)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
+1 -1
View File
@@ -17,7 +17,7 @@ def _droppy_listeners(cf):
for listener in cf["listeners"]: for listener in cf["listeners"]:
try: try:
if listener["protocol"] == "https": if listener["protocol"] == "https":
# TODO: Add support for TLS # TLS listeners are currently ignored here.
continue continue
socket = listener.get("socket") socket = listener.get("socket")
if socket: if socket:
+32 -35
View File
@@ -1,9 +1,9 @@
import asyncio
import os import os
import threading
from pathlib import Path
from cista import config from cista import config
from cista.util import filename from cista.util import filename
from cista.util.asynclink import AsyncLink
from cista.util.lrucache import LRUCache from cista.util.lrucache import LRUCache
@@ -32,20 +32,23 @@ class File:
if not self.writable: if not self.writable:
# Create/open file # Create/open file
self.open_rw() self.open_rw()
assert self.fd is not None if self.fd is None:
raise RuntimeError("file descriptor is not available for write")
if file_size is not None: if file_size is not None:
assert pos + len(buffer) <= file_size if pos + len(buffer) > file_size:
raise ValueError("write exceeds declared file size")
os.ftruncate(self.fd, file_size) os.ftruncate(self.fd, file_size)
if buffer: if buffer:
os.lseek(self.fd, pos, os.SEEK_SET) os.lseek(self.fd, pos, os.SEEK_SET)
os.write(self.fd, buffer) os.write(self.fd, buffer)
def __getitem__(self, slice): def __getitem__(self, slc):
if self.fd is None: if self.fd is None:
self.open_ro() self.open_ro()
assert self.fd is not None if self.fd is None:
os.lseek(self.fd, slice.start, os.SEEK_SET) raise RuntimeError("file descriptor is not available for read")
size = slice.stop - slice.start os.lseek(self.fd, slc.start, os.SEEK_SET)
size = slc.stop - slc.start
data = os.read(self.fd, size) data = os.read(self.fd, size)
if len(data) < size: if len(data) < size:
raise EOFError("Error reading requested range") raise EOFError("Error reading requested range")
@@ -62,38 +65,32 @@ class File:
class FileServer: class FileServer:
async def start(self): async def start(self):
self.alink = AsyncLink()
self.worker = asyncio.get_event_loop().run_in_executor(
None,
self.worker_thread,
self.alink.to_sync,
)
self.cache = LRUCache(File, capacity=10, maxage=5.0) self.cache = LRUCache(File, capacity=10, maxage=5.0)
self.cache_lock = threading.Lock()
self.file_locks: dict[str, threading.Lock] = {}
async def stop(self): async def stop(self):
await self.alink.stop()
await self.worker
def worker_thread(self, slink):
try:
for req in slink:
with req as (command, *args):
if command == "upload":
req.set_result(self.upload(*args))
elif command == "download":
req.set_result(self.download(*args))
else:
raise NotImplementedError(f"Unhandled {command=} {args}")
finally:
self.cache.close() self.cache.close()
def upload(self, name, pos, data, file_size): @staticmethod
name = filename.sanitize(name) def _stat_size(path):
f = self.cache[name] try:
f.write(pos, data, file_size=file_size) return Path(path).stat().st_size
return len(data) except FileNotFoundError:
return None
def download(self, name, start, end): def upload_info(self, name, pos, data, file_size):
name = filename.sanitize(name) name = filename.sanitize(name)
with self.cache_lock:
f = self.cache[name] f = self.cache[name]
return f[start:end] lock = self.file_locks.setdefault(name, threading.Lock())
with lock:
size_before = self._stat_size(f.path)
f.write(pos, data, file_size=file_size)
size_after = self._stat_size(f.path)
return {
"written": len(data),
"created": size_before is None,
"size_before": size_before,
"size_after": size_after,
}
+687
View File
@@ -0,0 +1,687 @@
import asyncio
import contextlib
import mimetypes
import os
import re
import shutil
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from urllib.parse import quote as url_quote
from urllib.parse import unquote, urlparse
from wsgiref.handlers import format_date_time
from sanic import Blueprint, HTTPResponse, empty, json
from sanic.exceptions import BadRequest, NotFound
from cista import auth, config, sharefs, watching
from cista.api import fileserver
from cista.util import filename
bp = Blueprint("fileserver", url_prefix="/files")
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
_FILE_CHUNK_SIZE = 1 << 20
_DAV_NS = "DAV:"
ET.register_namespace("D", _DAV_NS)
def _dav_tag(name: str) -> str:
return f"{{{_DAV_NS}}}{name}"
@bp.on_request
async def verify_fileserver(request):
"""Verify access to file server routes."""
await auth.verify(request)
@bp.put("/<name:path>")
async def upload_file_chunk(request, name):
auth.ensure_write_allowed(request)
body = request.body
header = request.headers.get("content-range")
if header:
start, end, total = _parse_content_range(header, len(body))
else:
start = 0
end = len(body)
total = end
rel, path = _safe_relpath(name, request=request)
rel_name = rel.as_posix()
upload_info = await asyncio.to_thread(
fileserver.upload_info,
rel_name,
start,
body,
total,
)
extras = []
chunk_len = end - start
whole_file = start == 0 and end == total
if not whole_file:
start_mib = _to_mib_int(start)
chunk_mib = _to_mib_int(chunk_len)
# Keep range logs compact for fixed-size upload blocks.
if chunk_mib == 16:
extras.append(f"{start_mib}MiB")
else:
extras.append(f"{start_mib}+{chunk_mib}MiB")
if upload_info.get("created"):
extras.append(f"created {_to_mib_int(total)}MiB")
size_before = upload_info.get("size_before")
size_after = upload_info.get("size_after")
if size_before is not None and size_after is not None and size_before != size_after:
extras.append("resized")
request.ctx._log_extra = " ".join(extras) if extras else None
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return json(
{
"status": "ack",
"req": {
"name": rel_name,
"size": total,
"start": start,
"end": end,
},
}
)
@bp.delete("/<name:path>")
async def delete_file(request, name):
auth.ensure_write_allowed(request)
rel, path = _safe_relpath(name, request=request)
if not rel.parts:
raise BadRequest("Refusing to delete root folder")
def _delete():
if not path.exists():
raise NotFound(f"File not found: {name}")
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
await asyncio.to_thread(_delete)
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return empty(status=204)
@bp.route("/<name:path>", methods=["MKCOL"])
async def create_folder(request, name):
auth.ensure_write_allowed(request)
rel, path = _safe_relpath(name, request=request)
if not rel.parts:
raise BadRequest("Refusing to create root folder")
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False)
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return empty(status=201)
@bp.post("/", name="post_root", strict_slashes=False)
@bp.post("/<name:path>", name="post_path")
async def copy_or_move(request, name=""):
auth.ensure_write_allowed(request)
provided_args = set(request.args.keys())
if not provided_args:
raise BadRequest("No query arguments passed")
allowed_args = {"cp", "mv"}
unknown_args = sorted(provided_args - allowed_args)
if unknown_args:
raise BadRequest(f"Unknown query parameter(s): {', '.join(unknown_args)}")
mv_vals = request.args.getlist("mv")
cp_vals = request.args.getlist("cp")
mv_keys: list[str] = []
for value in mv_vals:
mv_keys.extend(k for k in value.split() if k)
cp_keys: list[str] = []
for value in cp_vals:
cp_keys.extend(k for k in value.split() if k)
if not mv_keys and not cp_keys:
raise BadRequest("No keys given")
dst_rel, dst_abs = _safe_relpath(name, request=request)
dst_exists = dst_abs.exists()
dst_is_dir = dst_exists and dst_abs.is_dir()
ordered_keys = cp_keys + mv_keys
key_paths = _get_key_paths(request, set(ordered_keys))
missing = [key for key in ordered_keys if key not in key_paths]
if missing:
raise NotFound("Files not found", context={"missing": missing})
# Validate target shape/type before mutating anything.
for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
if len(op_keys) > 1 and not dst_is_dir:
raise BadRequest(
"Destination must be an existing directory for multiple keys"
)
if not op_keys:
continue
if not dst_is_dir:
if not dst_rel.parts:
raise BadRequest("Destination file path is required")
parent_abs = dst_abs.parent
if not parent_abs.is_dir():
raise BadRequest("Destination parent folder does not exist")
if dst_exists and dst_abs.is_file():
for key in op_keys:
src_abs = _resolve_from_relpath(key_paths[key])
if src_abs.is_dir():
raise BadRequest(
"Cannot move/copy a directory to an existing file"
)
changed: set[PurePosixPath] = set()
completed: list[dict[str, str]] = []
class _FileOpError(Exception):
def __init__(self, op_name: str, key: str, error: Exception):
self.op_name = op_name
self.key = key
self.error = error
super().__init__(str(error))
def _apply():
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
op_multi = len(op_keys) > 1
for key in op_keys:
try:
src_rel = key_paths[key]
src_abs = _resolve_from_relpath(src_rel, request=request)
if op_multi:
if not dst_is_dir:
raise BadRequest(
"Destination must be an existing directory for multiple keys"
)
dst_item_rel = (
dst_rel / src_rel.name
if dst_rel.parts
else PurePosixPath(src_rel.name)
)
elif dst_is_dir:
dst_item_rel = (
dst_rel / src_rel.name
if dst_rel.parts
else PurePosixPath(src_rel.name)
)
else:
if not dst_rel.parts:
raise BadRequest("Destination file path is required")
parent_abs = dst_abs.parent
if not parent_abs.is_dir():
raise BadRequest("Destination parent folder does not exist")
if src_abs.is_dir() and dst_exists and dst_abs.is_file():
raise BadRequest(
"Cannot move/copy a directory to an existing file"
)
dst_item_rel = dst_rel
dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
if op_name == "mv":
# A no-op rename should still return success.
if src_abs != dst_item_abs:
shutil.move(src_abs, dst_item_abs)
changed.add(src_rel)
changed.add(src_rel.parent)
elif src_abs.is_dir():
shutil.copytree(
src_abs,
dst_item_abs,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
)
else:
shutil.copy2(src_abs, dst_item_abs)
changed.add(dst_item_rel)
changed.add(dst_item_rel.parent)
completed.append({"op": op_name, "key": key})
except Exception as e:
raise _FileOpError(op_name, key, e) from e
try:
await asyncio.to_thread(_apply)
except _FileOpError as e:
raise BadRequest(
"File operation failed after partial progress",
context={
"failed_op": e.op_name,
"failed_key": e.key,
"error": str(e.error),
"completed": completed,
},
) from e
notify_paths = [p for p in changed if p.parts]
if notify_paths:
real_notify_paths: list[PurePosixPath] = []
for p in notify_paths:
real_abs = _resolve_from_relpath(p, request=request)
real_notify_paths.append(
PurePosixPath(
real_abs.relative_to(config.config.path.resolve()).as_posix()
)
)
watching.notify_change(*real_notify_paths)
return json(
{
"status": "ack",
"counts": {"cp": len(cp_keys), "mv": len(mv_keys)},
}
)
@bp.get("/<name:path>")
async def get_file(request, name=""):
return await _send_static_file(request, name, head_only=False)
@bp.head("/<name:path>")
async def head_file(request, name=""):
return await _send_static_file(request, name, head_only=True)
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
async def dav_options(request, name=""):
return HTTPResponse(
status=200,
headers={
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
"DAV": "1",
"MS-Author-Via": "DAV",
},
)
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
async def dav_propfind(request, name=""):
rel, path = _safe_relpath(name, request=request)
token = auth.request_share_token(request)
if token is not None and not rel.parts:
base = config.config.path.resolve()
entries = [_propfind_entry(PurePosixPath(), base)]
depth = request.headers.get("depth", "1").strip()
if depth == "infinity":
return HTTPResponse(status=403)
if depth == "1":
for root in sharefs.build_share_roots(token):
child_abs = (base / root.real_rel).resolve()
if not child_abs.exists() or not child_abs.is_relative_to(base):
continue
with contextlib.suppress(OSError):
entries.append(
_propfind_entry(PurePosixPath(root.alias), child_abs)
)
return HTTPResponse(
body=_build_propfind_xml(entries),
status=207,
content_type='application/xml; charset="utf-8"',
)
if not path.exists():
raise NotFound(f"Not found: {name}")
depth = request.headers.get("depth", "1").strip()
if depth == "infinity":
return HTTPResponse(status=403)
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
return HTTPResponse(
body=_build_propfind_xml(entries),
status=207,
content_type='application/xml; charset="utf-8"',
)
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
async def dav_copy(request, name=""):
auth.ensure_write_allowed(request)
dest_header = request.headers.get("destination")
if not dest_header:
raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
_src_rel, src_abs = _safe_relpath(name, request=request)
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
if auth.request_share_token(request) is not None and not dst_rel.parts:
raise BadRequest("Destination cannot be virtual root")
request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists():
raise NotFound(f"Source not found: {name}")
if src_abs == dst_abs:
raise BadRequest("Source and destination are the same")
dst_existed = dst_abs.exists()
if dst_existed and not overwrite:
return HTTPResponse(status=412)
if not dst_abs.parent.is_dir():
return HTTPResponse(status=409)
def _do_copy():
if dst_existed:
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
if src_abs.is_dir():
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
else:
shutil.copy2(src_abs, dst_abs)
await asyncio.to_thread(_do_copy)
real_dst_rel = PurePosixPath(
dst_abs.relative_to(config.config.path.resolve()).as_posix()
)
watching.notify_change(real_dst_rel, *real_dst_rel.parents)
return HTTPResponse(status=201 if not dst_existed else 204)
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
async def dav_move(request, name=""):
auth.ensure_write_allowed(request)
dest_header = request.headers.get("destination")
if not dest_header:
raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
_src_rel, src_abs = _safe_relpath(name, request=request)
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
if auth.request_share_token(request) is not None and not dst_rel.parts:
raise BadRequest("Destination cannot be virtual root")
request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists():
raise NotFound(f"Source not found: {name}")
if src_abs == dst_abs:
return HTTPResponse(status=204)
dst_existed = dst_abs.exists()
if dst_existed and not overwrite:
return HTTPResponse(status=412)
if not dst_abs.parent.is_dir():
return HTTPResponse(status=409)
def _do_move():
if dst_existed:
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
shutil.move(src_abs, dst_abs)
await asyncio.to_thread(_do_move)
real_src_rel = PurePosixPath(
src_abs.relative_to(config.config.path.resolve()).as_posix()
)
real_dst_rel = PurePosixPath(
dst_abs.relative_to(config.config.path.resolve()).as_posix()
)
watching.notify_change(
real_src_rel, *real_src_rel.parents, real_dst_rel, *real_dst_rel.parents
)
return HTTPResponse(status=201 if not dst_existed else 204)
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
if m is None:
raise BadRequest("Invalid Content-Range format")
start, end_inclusive, total = (int(v) for v in m.groups())
if total <= 0:
raise BadRequest("Invalid Content-Range total size")
if start > end_inclusive:
raise BadRequest("Invalid Content-Range range")
if end_inclusive >= total:
raise BadRequest("Content-Range exceeds total size")
expected_len = end_inclusive - start + 1
if expected_len != body_len:
raise BadRequest(
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
)
return start, end_inclusive + 1, total
def _to_mib_int(value_bytes: int) -> int:
return round(value_bytes / (1 << 20))
def _safe_relpath(path: str, *, request=None) -> tuple[PurePosixPath, Path]:
"""Resolve a user path under storage root and enforce containment."""
token = auth.request_share_token(request) if request is not None else None
if token is not None:
vrel, _rrel, resolved, is_root = sharefs.resolve_virtual_path(token, path)
if is_root:
return vrel, config.config.path.resolve()
return vrel, resolved
base = config.config.path.resolve()
try:
sanitized = filename.sanitize(unquote(path))
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
resolved = (base / sanitized).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
rel = PurePosixPath(resolved.relative_to(base).as_posix())
return rel, resolved
def _resolve_from_relpath(rel: PurePosixPath, *, request=None) -> Path:
"""Resolve a relative path under storage root and enforce containment."""
token = auth.request_share_token(request) if request is not None else None
if token is not None:
return sharefs.resolve_virtual_rel_to_real(token, rel)
base = config.config.path.resolve()
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
return resolved
async def _send_static_file(request, name: str, *, head_only: bool):
_, path = _safe_relpath(name, request=request)
try:
st = await asyncio.to_thread(path.stat)
except FileNotFoundError:
raise NotFound(f"File not found: {name}") from None
if path.is_dir():
raise NotFound(f"Not a file: {name}")
size = st.st_size
start = 0
end_excl = size
status = 200
range_header = request.headers.get("range")
if range_header is not None:
parsed = _parse_range_header(range_header, size)
if parsed is None:
return empty(
status=416,
headers={
"accept-ranges": "bytes",
"content-range": f"bytes */{size}",
},
)
start, end_excl = parsed
status = 206
length = end_excl - start
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
headers = {
"accept-ranges": "bytes",
"cache-control": "no-cache",
"content-length": str(length),
"content-type": mime,
"last-modified": format_date_time(st.st_mtime),
}
if status == 206:
headers["content-range"] = f"bytes {start}-{end_excl - 1}/{size}"
if head_only:
return empty(status=status, headers=headers)
res = await request.respond(status=status, headers=headers)
fd = await asyncio.to_thread(os.open, path, os.O_RDONLY)
try:
pos = start
while pos < end_excl:
chunk = await asyncio.to_thread(
os.pread,
fd,
min(_FILE_CHUNK_SIZE, end_excl - pos),
pos,
)
if not chunk:
break
pos += len(chunk)
await res.send(chunk)
finally:
await asyncio.to_thread(os.close, fd)
def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
value = header.strip()
if "," in value:
return None
m = _RANGE_RE.fullmatch(value)
if m is None:
return None
start_s, end_s = m.groups()
if not start_s and not end_s:
return None
if start_s:
start = int(start_s)
if start >= size:
return None
end_inclusive = int(end_s) if end_s else (size - 1)
if end_inclusive < start:
return None
end_inclusive = min(end_inclusive, size - 1)
return start, end_inclusive + 1
suffix_len = int(end_s)
if suffix_len <= 0:
return None
if suffix_len >= size:
return 0, size
start = size - suffix_len
return start, size
def _get_key_paths(request, wanted: set[str]) -> dict[str, PurePosixPath]:
"""Map file keys to their current relative filesystem paths."""
token = auth.request_share_token(request)
if token is not None:
return sharefs.key_paths_for_token(token, wanted)
loc = PurePosixPath()
ret: dict[str, PurePosixPath] = {}
with watching.state.lock:
root = watching.state.root
for f in root:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if f.key in wanted and f.key not in ret:
ret[f.key] = loc
if len(ret) == len(wanted):
break
return ret
# ---------------------------------------------------------------------------
# WebDAV helpers
# ---------------------------------------------------------------------------
def _parse_webdav_destination(
dest_header: str, *, request=None
) -> tuple[PurePosixPath, Path]:
"""Parse a WebDAV Destination header and resolve it to a storage path."""
parsed = urlparse(dest_header)
raw_path = parsed.path # still percent-encoded
prefix = "/files"
if raw_path in (prefix, prefix + "/"):
rel_str = ""
elif raw_path.startswith(prefix + "/"):
rel_str = raw_path[len(prefix) + 1 :]
else:
raise BadRequest("Destination must be within /files")
return _safe_relpath(rel_str, request=request)
def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
"""Build a DAV href from a storage-relative path."""
parts = rel.parts
if not parts:
return "/files/"
encoded = "/".join(url_quote(p, safe="") for p in parts)
href = f"/files/{encoded}"
return href + "/" if is_dir else href
def _dav_xml(element: ET.Element) -> bytes:
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
return b'<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(
element, encoding="unicode"
).encode("utf-8")
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
entries = [_propfind_entry(rel, path)]
if depth == "1" and path.is_dir():
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
with contextlib.suppress(OSError):
entries.append(_propfind_entry(child_rel, child))
return entries
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
st = path.stat()
is_dir = path.is_dir()
return {
"href": _rel_to_href(rel, is_dir=is_dir),
"name": rel.parts[-1] if rel.parts else "",
"is_dir": is_dir,
"size": st.st_size,
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
"content_type": mimetypes.guess_type(path.name)[0]
or "application/octet-stream",
"last_modified": format_date_time(st.st_mtime),
"created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
}
def _build_propfind_xml(entries: list[dict]) -> bytes:
multistatus = ET.Element(_dav_tag("multistatus"))
for e in entries:
response = ET.SubElement(multistatus, _dav_tag("response"))
ET.SubElement(response, _dav_tag("href")).text = e["href"]
propstat = ET.SubElement(response, _dav_tag("propstat"))
prop = ET.SubElement(propstat, _dav_tag("prop"))
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
if e["is_dir"]:
ET.SubElement(rt, _dav_tag("collection"))
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
if not e["is_dir"]:
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
return _dav_xml(multistatus)
+184
View File
@@ -0,0 +1,184 @@
"""OnlyOffice Document Server integration for office document preview.
Provides server-side conversion of office documents to PNG via the
OnlyOffice Document Server /ConvertService.ashx API. The resulting PNG
is passed through pyvips for AVIF compression.
Environment requirements:
- OnlyOffice Document Server must be running and reachable.
- If Document Server runs in Docker, the callback host IP must be
reachable from the container (usually the docker bridge IP).
"""
import json
import os
import socket
import socketserver
import subprocess
import threading
import urllib.request
from functools import partial
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from time import perf_counter
from urllib.parse import quote
import jwt
from sanic.log import logger
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
def _get_onlyoffice_url() -> str:
return os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
def _get_jwt_secret() -> str | None:
return os.environ.get("ONLYOFFICE_JWT_SECRET") or None
def _get_callback_host() -> str:
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
return host
# Try to auto-detect docker bridge IP
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
for line in result.stdout.splitlines():
if "inet " in line:
parts = line.strip().split()
addr_part = parts[1] # e.g. 172.17.0.1/16
return addr_part.split("/")[0]
except Exception:
logger.debug("Failed to auto-detect docker bridge IP")
return "127.0.0.1"
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------
def is_available() -> bool:
"""Return True if the configured OnlyOffice Document Server is reachable."""
url = _get_onlyoffice_url()
try:
with urllib.request.urlopen(url, timeout=3) as resp: # noqa: S310
return resp.status == 200
except Exception:
return False
# ---------------------------------------------------------------------------
# Temporary HTTP server so OnlyOffice can download the file
# ---------------------------------------------------------------------------
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args) -> None:
pass
def _get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
return s.getsockname()[1]
def _serve_file_temporarily(file_path: Path):
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
# ---------------------------------------------------------------------------
# OnlyOffice conversion client
# ---------------------------------------------------------------------------
def _build_jwt_token(payload: dict) -> str | None:
secret = _get_jwt_secret()
if not secret:
return None
return jwt.encode(payload, secret, algorithm="HS256")
def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
"""Convert *file_path* to PNG using OnlyOffice Document Server.
Returns the PNG bytes. Raises RuntimeError on failure.
"""
oo_url = _get_onlyoffice_url().rstrip("/")
convert_url = f"{oo_url}/ConvertService.ashx"
# Start temporary HTTP server so OnlyOffice can fetch the file
doc_url, httpd = _serve_file_temporarily(file_path)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
"async": False,
"filetype": suffix,
"key": f"cista_{file_path.stat().st_mtime_ns}",
"outputtype": "png",
"title": file_path.name,
"url": doc_url,
}
headers = {"Content-Type": "application/json"}
token = _build_jwt_token(payload)
if token:
# Conversion API expects JWT in request body when token checks are enabled.
payload["token"] = token
headers["Authorization"] = token
req = urllib.request.Request( # noqa: S310
convert_url,
data=json.dumps(payload).encode(),
headers=headers,
method="POST",
)
t_start = perf_counter()
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
body = resp.read()
t_end = perf_counter()
# Parse XML response
text = body.decode("utf-8", errors="replace")
if "<Error>" in text:
code = "unknown"
if "<Error>" in text and "</Error>" in text:
code = text.split("<Error>")[1].split("</Error>")[0]
raise RuntimeError(f"OnlyOffice conversion error: {code}")
if "<FileUrl>" not in text:
raise RuntimeError("OnlyOffice response did not contain FileUrl")
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
file_url = file_url.replace("&amp;", "&")
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
# Download converted PNG
with urllib.request.urlopen(file_url, timeout=timeout) as png_resp: # noqa: S310
return png_resp.read()
finally:
httpd.shutdown()
+584 -75
View File
@@ -1,8 +1,15 @@
import asyncio import asyncio
import contextlib
import gc import gc
import io import io
import mimetypes import mimetypes
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
@@ -10,123 +17,579 @@ from wsgiref.handlers import format_date_time
import av import av
import fitz # PyMuPDF import fitz # PyMuPDF
import msgspec
import numpy as np import numpy as np
import pillow_heif import pyvips
from PIL import Image from blake3 import blake3
from sanic import Blueprint, empty, raw, redirect 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 auth, config from cista import auth, config, sharefs
from cista.preview_worker import PreviewRequest, PreviewResponse
from cista.util.filename import sanitize from cista.util.filename import sanitize
pillow_heif.register_heif_opener() # OnlyOffice integration is loaded lazily; availability is checked at runtime.
_onlyoffice = None
def _get_onlyoffice():
global _onlyoffice
if _onlyoffice is None:
try:
from cista import onlyoffice as oo
_onlyoffice = oo
except Exception:
_onlyoffice = False
return _onlyoffice
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 = 10.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
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",
stderr=resp.stderr,
backend=resp.backend,
)
return payload or None, resp
async def kill(self) -> None:
if self.proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
self.proc.kill()
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._pending: asyncio.PriorityQueue[tuple[int, int, asyncio.Future, tuple]] = (
asyncio.PriorityQueue()
)
self._workers: set[_PreviewWorker] = set()
self._dispatchers: list[asyncio.Task] = []
self._seq = 0
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 _dispatch_loop(self) -> None:
while True:
try:
_priority, _seq, future, args = await self._pending.get()
except asyncio.CancelledError:
return
if future.cancelled():
continue
worker = await self._idle.get()
filepath = args[0]
replace = False
try:
out, resp = await asyncio.wait_for(
worker.request(*args),
timeout=PREVIEW_TIMEOUT,
)
if not future.done():
future.set_result((out, resp))
except TimeoutError:
replace = True
logger.warning(
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
)
if not future.done():
future.set_exception(PreviewTimeoutError(filepath.name))
except WorkerChecksumError:
replace = True
logger.error("Preview checksum mismatch for %s", filepath.name)
if not future.done():
future.set_exception(
PreviewError(f"worker checksum mismatch for {filepath.name}")
)
except PreviewError as e:
if not future.done():
future.set_exception(e)
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
)
if not future.done():
future.set_exception(
PreviewError(
f"worker protocol failure for {filepath.name}: {e}"
)
)
finally:
if replace:
await self._replace_worker(worker)
elif worker.proc.returncode is None:
await self._idle.put(worker)
else:
await self._replace_worker(worker)
async def start(self) -> None:
for _ in range(self.size):
await self._add_worker()
for _ in range(self.size):
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
if self._closed:
raise PreviewError("preview worker pool closed")
loop = asyncio.get_running_loop()
future = loop.create_future()
self._seq += 1
await self._pending.put(
(
_preview_job_priority(filepath),
self._seq,
future,
(filepath, quality, maxsize, maxzoom),
)
)
return await future
async def close(self) -> None:
self._closed = True
for task in self._dispatchers:
task.cancel()
if self._dispatchers:
await asyncio.gather(*self._dispatchers, return_exceptions=True)
self._dispatchers.clear()
workers = list(self._workers)
self._workers.clear()
while not self._pending.empty():
try:
_priority, _seq, future, _args = self._pending.get_nowait()
except asyncio.QueueEmpty:
break
if not future.done():
future.set_exception(PreviewError("preview worker pool closed"))
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):
with contextlib.suppress(ProcessLookupError):
proc.kill()
await asyncio.gather(
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
)
_active_procs.clear()
@bp.on_request @bp.on_request
async def verify_preview(request): async def verify_preview(request):
"""Verify access to preview routes.""" """Verify access to preview routes."""
await auth.verify(request) await auth.verify(request)
# Map EXIF Orientation value to a corresponding PIL transpose class PreviewTimeoutError(Exception):
EXIF_ORI = { """Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM, class PreviewError(Exception):
5: Image.Transpose.TRANSPOSE, """Raised when the preview subprocess exits with a non-zero status."""
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE, def __init__(
8: Image.Transpose.ROTATE_90, self,
message: str,
*,
stderr: str | None = None,
backend: str | None = None,
):
super().__init__(message)
self.stderr = stderr
self.backend = backend
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(f"preview worker pool unavailable for {filepath.name}")
return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
OFFICE_PREVIEW_SUFFIXES = {
".doc",
".dot",
".docx",
".docm",
".dotx",
".dotm",
".rtf",
".odt",
".ott",
".txt",
".md",
".mhtml",
".mht",
".html",
".htm",
".xml",
".wps",
".wri",
# Spreadsheets
".xls",
".xlsx",
".xlsm",
".xlsb",
".xltx",
".xltm",
".ods",
".ots",
".csv",
# Presentations
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
".odp",
".otp",
} }
def _preview_job_priority(path) -> int:
"""Return priority for preview job (lower=higher priority).
Priority order: images (0) < video (1) < PDF (2) < office (3) < unknown (4)
"""
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
return 2
if suffix in OFFICE_PREVIEW_SUFFIXES:
return 3
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("image/"):
return 0
if mime_type and mime_type.startswith("video/"):
return 1
return 4
def is_previewable_path(path) -> bool:
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
return True
mime_type, _ = mimetypes.guess_type(path.name)
if not mime_type:
return False
return mime_type.startswith(("image/", "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"""
maxsize = int(req.args.get("px", 1024)) maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60)) quality = int(req.args.get("q", 60))
share_token = auth.request_share_token(req)
if share_token is not None:
rel, _real_rel, filepath, is_root = sharefs.resolve_virtual_path(
share_token, path
)
if is_root:
raise NotFound from None
else:
rel = PurePosixPath(sanitize(unquote(path))) rel = PurePosixPath(sanitize(unquote(path)))
filepath = config.config.path / rel filepath = config.config.path / rel
try:
stat = filepath.lstat() 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()
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 PreviewTimeoutError:
return empty(504)
except PreviewError as e:
if e.backend:
req.ctx._log_extra = e.backend
detail = str(e)
if detail == "preview worker error" and e.stderr:
captured = e.stderr.strip()
if captured:
detail = captured.splitlines()[0]
logger.error("%s preview: %s", filepath, detail)
return empty(422)
if preview_resp and preview_resp.backend:
if preview_resp.timings:
timing_detail = "/".join(
str(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") 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 filepath.is_file():
raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
)
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
return raw(img, headers=headers) return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
backend = "unknown"
try: try:
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"): suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
backend = "pdf"
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
type, _ = mimetypes.guess_type(path.name) if suffix in OFFICE_PREVIEW_SUFFIXES:
if type and type.startswith("video/"): backend = "onlyoffice"
return process_office(
path, quality=quality, maxsize=maxsize, maxzoom=maxzoom
)
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"):
backend = "video"
return process_video(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
if mime_type and mime_type.startswith("image/"):
backend = "pyvips"
return process_image(path, quality=quality, maxsize=maxsize) return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e: except ValueError as e:
logger.warning(f"Cannot generate preview for {path}: {e}") return None, PreviewResponse(ok=False, backend=backend, error=str(e))
except Exception as e: except Exception as e:
logger.exception(f"Error generating preview for {path}: {e}") return None, PreviewResponse(ok=False, backend=backend, error=str(e))
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
t_load = perf_counter() return process_image_pyvips(path, maxsize=maxsize, quality=quality)
with Image.open(path) as img:
# Force decode to include I/O in load timing
img.load()
t_proc = perf_counter()
# Resize
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Transpose pixels according to EXIF Orientation
orientation = img.getexif().get(274, 1)
if orientation in EXIF_ORI:
img = img.transpose(EXIF_ORI[orientation])
# Save as AVIF
imgdata = io.BytesIO()
t_save = perf_counter()
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_end = perf_counter()
ret = imgdata.getvalue()
load_ms = (t_proc - t_load) * 1000 def process_image_pyvips(path, *, maxsize, quality):
proc_ms = (t_save - t_proc) * 1000 t_start = perf_counter()
save_ms = (t_end - t_save) * 1000 img = pyvips.Image.new_from_file(str(path), access="sequential")
logger.debug( img = img.autorot()
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms", scale = min(maxsize / img.width, maxsize / img.height, 1.0)
path.name, if scale < 1.0:
load_ms, img = img.resize(scale)
proc_ms, ret = img.write_to_buffer(
save_ms, ".avif",
Q=quality,
effort=AVIF_FAST_EFFORT,
strip=True,
) )
t_end = perf_counter()
return ret return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend="pyvips",
timings=[round((t_end - t_start) * 1000, 1)],
)
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0): def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
@@ -140,16 +603,52 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
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) 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),
],
)
def process_office(path, *, quality, maxsize, maxzoom):
t_load_start = perf_counter()
oo = _get_onlyoffice()
if oo is False:
raise RuntimeError("OnlyOffice is not installed")
if not oo.is_available():
raise RuntimeError("OnlyOffice Document Server is not reachable")
png_bytes = oo.convert_to_png(path)
t_load_end = perf_counter()
t_save_start = perf_counter()
img = pyvips.Image.new_from_buffer(png_bytes, "")
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)
backend = "onlyoffice+pyvips"
t_save_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend=backend,
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):
@@ -162,7 +661,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]
@@ -237,7 +742,8 @@ def process_video(path, *, maxsize, quality):
"threads": "1", "threads": "1",
}, },
) )
assert isinstance(ostream, av.VideoStream) if not isinstance(ostream, av.VideoStream):
raise PreviewError("failed to initialize AV1 video stream")
ostream.width = frame.width ostream.width = frame.width
ostream.height = frame.height ostream.height = frame.height
ostream.pix_fmt = frame.format.name ostream.pix_fmt = frame.format.name
@@ -254,14 +760,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
+116
View File
@@ -0,0 +1,116 @@
"""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 contextlib
import io
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
stderr: 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
stderr_capture = io.StringIO()
handler = logging.StreamHandler(stderr_capture)
root_logger = logging.getLogger()
root_logger.addHandler(handler)
try:
with contextlib.redirect_stderr(stderr_capture):
req = _dec_req.decode(line)
result, resp = dispatch(
Path(req.path), req.quality, req.maxsize, req.maxzoom
)
if not resp.ok:
captured = stderr_capture.getvalue().strip()
if captured:
resp = PreviewResponse(
ok=False,
backend=resp.backend,
error=resp.error,
stderr=captured,
)
_write_response(resp, result or b"")
except Exception as e:
captured = stderr_capture.getvalue().strip()
_write_response(
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
)
finally:
root_logger.removeHandler(handler)
handler.close()
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()
+3 -101
View File
@@ -1,108 +1,8 @@
from __future__ import annotations from __future__ import annotations
import shutil
from typing import Any from typing import Any
import msgspec import msgspec
from sanic import BadRequest
from cista import config
from cista.util import filename
## Control commands
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self):
raise NotImplementedError
class MkDir(ControlBase):
path: str
def __call__(self):
path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=True, exist_ok=False)
class Rename(ControlBase):
path: str
to: str
def __call__(self):
to = filename.sanitize(self.to)
if "/" in to:
raise BadRequest("Rename 'to' name should only contain filename, not path")
path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to))
class Rm(ControlBase):
sel: list[str]
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
for p in sel:
if p.is_dir():
shutil.rmtree(p)
else:
p.unlink()
class Mv(ControlBase):
sel: list[str]
dst: str
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
dst = root / filename.sanitize(self.dst)
if not dst.is_dir():
raise BadRequest("The destination must be a directory")
for p in sel:
shutil.move(p, dst)
class Cp(ControlBase):
sel: list[str]
dst: str
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
dst = root / filename.sanitize(self.dst)
if not dst.is_dir():
raise BadRequest("The destination must be a directory")
for p in sel:
if p.is_dir():
# Note: copies as dst rather than in dst unless name is appended.
shutil.copytree(
p,
dst / p.name,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
)
else:
shutil.copy2(p, dst)
ControlTypes = MkDir | Rename | Rm | Mv | Cp
## File uploads and downloads
class FileRange(msgspec.Struct):
name: str
size: int
start: int
end: int
class StatusMsg(msgspec.Struct):
status: str
req: FileRange
class ErrorMsg(msgspec.Struct): class ErrorMsg(msgspec.Struct):
@@ -118,6 +18,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 +50,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
+275
View File
@@ -0,0 +1,275 @@
"""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, extra: str | None = None
) -> 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}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info(
"%s %s %s %s %s%s",
" " * 19,
id_str,
method_str,
status_str,
timing_str,
extra_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: "i",
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, "▪️")
sep = " " if record.levelno in (logging.INFO, logging.WARNING) else " "
return f"{emoji}{sep}{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())
+21 -15
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
@@ -11,7 +12,7 @@ def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app from .app import app
url, opts = parse_listen(config.config.listen) _url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
confdir = config.conffile.parent confdir = config.conffile.parent
@@ -20,14 +21,14 @@ def run(*, dev=False):
server80.app.prepare(port=80, motd=False) server80.app.prepare(port=80, motd=False)
domain = opts["host"] domain = opts["host"]
check_cert(confdir / domain, domain) check_cert(confdir / domain, domain)
opts["ssl"] = str(confdir / domain) # type: ignore opts["ssl"] = str(confdir / domain) # type: ignore[assignment]
app.prepare( app.prepare(
**opts, **opts,
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
access_log=True, access_log=False,
) # type: ignore ) # type: ignore[call-arg]
if dev: if dev:
Sanic.serve() Sanic.serve()
else: else:
@@ -37,25 +38,30 @@ def run(*, dev=False):
def check_cert(certdir, domain): def check_cert(certdir, domain):
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists(): if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
return return
# TODO: Use certbot to fetch a cert # Certificate provisioning is external; files must exist before startup.
raise ValueError( raise ValueError(
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}", f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
) )
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}
+41 -28
View File
@@ -1,43 +1,56 @@
import secrets
from time import time from time import time
import jwt # In-memory session store: token -> {"username": str, "exp": int}
_sessions: dict[str, dict] = {}
from cista.config import derived_secret
def session_secret():
return derived_secret("session")
SESSION_COOKIE_NAME = "cista"
max_age = 365 * 86400 # Seconds since last login max_age = 365 * 86400 # Seconds since last login
def _token() -> str:
return secrets.token_urlsafe(8)
def _purge_expired() -> None:
now = time()
expired = [t for t, s in _sessions.items() if s["exp"] <= now]
for t in expired:
del _sessions[t]
def get(request): def get(request):
try: token = request.cookies.get(SESSION_COOKIE_NAME)
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) if token is None:
except Exception: return None
return False if "s" in request.cookies else None s = _sessions.get(token)
if s is None:
return False # Cookie present but session not found / expired
if s["exp"] <= time():
del _sessions[token]
return False
return s
def create(res, username, **kwargs): def create(request, res, username, **kwargs):
data = { _purge_expired()
"exp": int(time()) + max_age, token = _token()
"username": username, _sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
**kwargs, secure = request.scheme == "https"
} res.cookies.add_cookie(
s = jwt.encode(data, session_secret()) SESSION_COOKIE_NAME,
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) token,
httponly=True,
max_age=max_age,
secure=secure,
host_prefix=secure,
)
def update(res, s, **kwargs): def delete(request, res):
s.update(kwargs) secure = request.scheme == "https"
s = jwt.encode(s, session_secret()) res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
max_age = max(1, s["exp"] - int(time())) # type: ignore
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
def delete(res):
res.cookies.delete_cookie("s")
def flash(res, message: str | None): def flash(res, message: str | None):
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG
from time import time
from typing import NamedTuple
from natsort import humansorted
from sanic.exceptions import BadRequest, NotFound
from cista import config, watching
from cista.fileio import fuid
from cista.protocol import FileEntry
from cista.util.filename import sanitize
class ShareRootEntry(NamedTuple):
alias: str
real_rel: PurePosixPath
def _token_is_share(token: config.Token) -> bool:
return token.kind == "share" and bool(token.share_paths)
def is_share_token(token: config.Token | None) -> bool:
return bool(token and _token_is_share(token))
def build_share_roots(token: config.Token) -> list[ShareRootEntry]:
if not _token_is_share(token):
return []
base = config.config.path.resolve()
roots: list[ShareRootEntry] = []
used_aliases: set[str] = set()
for raw_path in token.share_paths:
try:
clean = sanitize(raw_path)
except ValueError:
continue
if not clean:
continue
rel = PurePosixPath(clean)
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base) or not resolved.exists():
continue
display = rel.name or config.config.path.name
alias = display
suffix = 2
while alias in used_aliases:
alias = f"{display} ({suffix})"
suffix += 1
used_aliases.add(alias)
roots.append(ShareRootEntry(alias=alias, real_rel=rel))
return roots
def resolve_virtual_path(
token: config.Token,
raw_path: str,
) -> tuple[PurePosixPath, PurePosixPath, Path, bool]:
"""Resolve a share-virtual path to real path.
Returns (virtual_rel, real_rel, real_abs, is_virtual_root).
"""
base = config.config.path.resolve()
if raw_path.strip("/") == "":
return PurePosixPath(), PurePosixPath(), base, True
try:
clean = sanitize(raw_path)
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
if not clean:
return PurePosixPath(), PurePosixPath(), base, True
virtual_rel = PurePosixPath(clean)
roots = build_share_roots(token)
if not roots:
raise NotFound("Share token has no visible files")
root_by_alias = {r.alias: r.real_rel for r in roots}
first = virtual_rel.parts[0]
real_root = root_by_alias.get(first)
if real_root is None:
raise NotFound(f"Not found: {raw_path}")
rest = virtual_rel.parts[1:]
real_rel = real_root.joinpath(*rest) if rest else real_root
resolved = (base / real_rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
return virtual_rel, real_rel, resolved, False
def real_to_virtual_aliases(token: config.Token) -> dict[PurePosixPath, str]:
return {entry.real_rel: entry.alias for entry in build_share_roots(token)}
def _walk_virtual_entry(path: Path, name: str, level: int) -> list[FileEntry]:
st = path.lstat()
is_dir = S_ISDIR(st.st_mode)
is_file = S_ISREG(st.st_mode)
if not is_dir and not is_file:
return []
if is_file:
try:
allocated = watching.get_allocated_size(path, st)
except Exception:
allocated = st.st_size
return [
FileEntry(
level=level,
name=name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size,
allocated=allocated,
isfile=1,
)
]
children: list[tuple[int, str, object]] = []
for child in path.iterdir():
if child.name.startswith("."):
continue
try:
cst = child.lstat()
except FileNotFoundError:
continue
c_is_file = S_ISREG(cst.st_mode)
c_is_dir = S_ISDIR(cst.st_mode)
if not c_is_file and not c_is_dir:
continue
children.append((int(c_is_file), child.name, cst))
entries: list[FileEntry] = []
agg_mtime = int(st.st_mtime)
agg_size = 0
agg_alloc = 0
for _, child_name, _ in humansorted(children):
child_path = path / child_name
child_entries = _walk_virtual_entry(child_path, child_name, level + 1)
if not child_entries:
continue
head = child_entries[0]
agg_mtime = max(agg_mtime, head.mtime)
agg_size += head.size
agg_alloc += head.allocated
entries.extend(child_entries)
head = FileEntry(
level=level,
name=name,
key=fuid(st),
mtime=agg_mtime,
size=agg_size,
allocated=agg_alloc,
isfile=0,
)
return [head, *entries]
def build_virtual_root(token: config.Token) -> list[FileEntry]:
roots = build_share_roots(token)
now = int(time())
root_key = config.derived_secret("share-root", token.key or "", token.created).hex()
entries: list[FileEntry] = []
total_size = 0
total_alloc = 0
root_mtime = 0
base = config.config.path.resolve()
for entry in roots:
real_abs = (base / entry.real_rel).resolve()
if not real_abs.is_relative_to(base) or not real_abs.exists():
continue
try:
subtree = _walk_virtual_entry(real_abs, entry.alias, 1)
except OSError:
continue
if not subtree:
continue
head = subtree[0]
total_size += head.size
total_alloc += head.allocated
root_mtime = max(root_mtime, head.mtime)
entries.extend(subtree)
root = FileEntry(
level=0,
name="",
key=root_key,
mtime=root_mtime or now,
size=total_size,
allocated=total_alloc,
isfile=0,
)
return [root, *entries]
def key_paths_for_token(
token: config.Token, wanted: set[str]
) -> dict[str, PurePosixPath]:
ret: dict[str, PurePosixPath] = {}
loc = PurePosixPath()
root = build_virtual_root(token)
for f in root:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if f.key in wanted and f.key not in ret:
ret[f.key] = loc
if len(ret) == len(wanted):
break
return ret
def resolve_virtual_rel_to_real(token: config.Token, rel: PurePosixPath) -> Path:
_vrel, _rrel, real_abs, is_root = resolve_virtual_path(token, rel.as_posix())
if is_root:
raise BadRequest("Virtual root is not a writable filesystem path")
return real_abs
+91 -44
View File
@@ -15,7 +15,8 @@ import re
import httpx import httpx
import websockets import websockets
from sanic import Blueprint from sanic import Blueprint, json
from sanic import raw as raw_response
from sanic.exceptions import Forbidden, SanicException, Unauthorized from sanic.exceptions import Forbidden, SanicException, Unauthorized
from sanic.log import logger from sanic.log import logger
@@ -48,6 +49,8 @@ async def get_client() -> httpx.AsyncClient:
global _client global _client
if _client is None or _client.is_closed: if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=1.0) _client = httpx.AsyncClient(timeout=1.0)
if "user-agent" in _client.headers:
del _client.headers["user-agent"] # No httpx UA
return _client return _client
@@ -123,13 +126,12 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
context=error_data, context=error_data,
quiet=True, quiet=True,
) )
elif response.status_code == 403: if response.status_code == 403:
raise Forbidden( raise Forbidden(
error_data.get("detail", "Access denied"), error_data.get("detail", "Access denied"),
context=error_data, context=error_data,
quiet=True, quiet=True,
) )
else:
detail = error_data.get("detail", "") detail = error_data.get("detail", "")
logger.warning( logger.warning(
f"SSO validation {url} returned {response.status_code}: {detail}" f"SSO validation {url} returned {response.status_code}: {detail}"
@@ -146,8 +148,64 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
"Authentication service unavailable", "Authentication service unavailable",
status_code=502, status_code=502,
quiet=True, quiet=True,
) from e
async def check_permissions(user_id: str, perm: str) -> dict:
"""Check if a Paskia user has the given permission.
Calls /auth/api/check?user=<UUID>&perm=<perm> — no session or cookies needed.
Args:
user_id: The Paskia user UUID
perm: Permission to check (e.g. cista:login or cista:admin)
Returns:
User info dict if permission is granted
Raises:
Forbidden: If permission is denied or check fails
SanicException: If the auth service is unreachable
"""
if not paskia_enabled():
raise ValueError("Paskia not enabled")
client = await get_client()
url = f"{PASKIA_BACKEND_URL}/auth/api/check"
try:
response = await client.get(
url,
params={"user": user_id, "perm": perm},
headers={"accept": "application/json"},
) )
if response.status_code == 200:
return response.json()
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text or "Permission check failed"}
if response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
quiet=True,
)
raise Forbidden(
error_data.get("detail", "Permission check failed"),
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"Permission check {url} network error: {e}")
raise SanicException(
"Authentication service unavailable",
status_code=502,
quiet=True,
) from e
async def proxy_auth_request(request): async def proxy_auth_request(request):
"""Proxy a request to the auth backend. """Proxy a request to the auth backend.
@@ -171,10 +229,10 @@ async def proxy_auth_request(request):
"upgrade", "upgrade",
"proxy-authorization", "proxy-authorization",
"proxy-authenticate", "proxy-authenticate",
"forwarded",
"x-forwarded-for", "x-forwarded-for",
"x-forwarded-host", "x-forwarded-host",
"x-forwarded-proto", "x-forwarded-proto",
"forwarded",
} }
headers = [ headers = [
@@ -182,9 +240,17 @@ async def proxy_auth_request(request):
for key, value in request.headers.items() for key, value in request.headers.items()
if key.lower() not in skip_headers if key.lower() not in skip_headers
] ]
headers.append(("x-forwarded-for", request.client_ip))
# 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-host", request.host))
headers.append(("x-forwarded-proto", request.scheme)) headers.append(("x-forwarded-proto", request.scheme))
headers.append(
(
"forwarded",
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
)
)
try: try:
async with client.stream( async with client.stream(
@@ -210,8 +276,6 @@ async def proxy_auth_request(request):
if key.lower() not in resp_hop_by_hop if key.lower() not in resp_hop_by_hop
] ]
from sanic import raw as raw_response
return raw_response( return raw_response(
raw_content, raw_content,
status=response.status_code, status=response.status_code,
@@ -221,35 +285,31 @@ async def proxy_auth_request(request):
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"Auth proxy request failed: {e}") logger.error(f"Auth proxy request failed: {e}")
from sanic import json
return json( return json(
{"detail": "Authentication service unavailable", "error": str(e)}, {"detail": "Authentication service unavailable"},
status=503, status=503,
) )
async def proxy_auth_websocket(request, ws): async def proxy_auth_websocket(request, ws):
"""Proxy a WebSocket connection to the auth backend.""" """Proxy a WebSocket connection to the auth backend."""
path = request.path url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
query_string = request.query_string if request.query_string:
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace( url = f"{url}?{request.query_string}"
"https://", "wss://"
)
url = f"{ws_backend}{path}"
if query_string:
url = f"{url}?{query_string}"
additional_headers = {} additional_headers = {}
if "cookie" in request.headers: if "cookie" in request.headers:
additional_headers["cookie"] = request.headers["cookie"] additional_headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers: if "authorization" in request.headers:
additional_headers["authorization"] = request.headers["authorization"] additional_headers["authorization"] = request.headers["authorization"]
if "host" in request.headers:
additional_headers["host"] = request.headers["host"]
if "origin" in request.headers: if "origin" in request.headers:
additional_headers["origin"] = request.headers["origin"] additional_headers["origin"] = request.headers["origin"]
if "user-agent" in request.headers: if "user-agent" in request.headers:
additional_headers["user-agent"] = request.headers["user-agent"] additional_headers["user-agent"] = request.headers["user-agent"]
additional_headers["x-forwarded-for"] = request.ip additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
additional_headers["x-forwarded-host"] = request.host additional_headers["x-forwarded-host"] = request.host
additional_headers["x-forwarded-proto"] = request.scheme additional_headers["x-forwarded-proto"] = request.scheme
@@ -262,15 +322,15 @@ async def proxy_auth_websocket(request, ws):
try: try:
async for message in ws: async for message in ws:
await backend_ws.send(message) await backend_ws.send(message)
except Exception: except Exception as e:
pass logger.debug("WebSocket forward_to_backend ended: %s", e)
async def forward_to_client(): async def forward_to_client():
try: try:
async for message in backend_ws: async for message in backend_ws:
await ws.send(message) await ws.send(message)
except Exception: except Exception as e:
pass logger.debug("WebSocket forward_to_client ended: %s", e)
await asyncio.gather( await asyncio.gather(
forward_to_backend(), forward_to_backend(),
@@ -281,23 +341,20 @@ async def proxy_auth_websocket(request, ws):
logger.error(f"WebSocket proxy to {url} failed: {e}") logger.error(f"WebSocket proxy to {url} failed: {e}")
def _is_websocket_request(request) -> bool: # Blueprint for auth proxy routes (only registered when paskia_enabled())
"""Check if the request is a WebSocket upgrade request.""" bp = Blueprint("sso", url_prefix="/auth")
connection = request.headers.get("connection", "").lower()
upgrade = request.headers.get("upgrade", "").lower()
connection_tokens = [t.strip() for t in connection.split(",")]
return "upgrade" in connection_tokens and upgrade == "websocket"
async def _handle_websocket_upgrade(request): @bp.websocket("/ws/<path:path>")
"""Handle WebSocket upgrade and proxy the connection.""" async def auth_websocket_proxy(request, ws, path=""):
protocol = request.transport.get_protocol() """Proxy WebSocket connections to the auth backend."""
ws = await protocol.websocket_handshake(request, subprotocols=None)
await proxy_auth_websocket(request, ws) await proxy_auth_websocket(request, ws)
# Blueprint for auth proxy routes (only registered when paskia_enabled()) @bp.websocket("/ws/")
bp = Blueprint("sso", url_prefix="/auth") async def auth_websocket_proxy_root(request, ws):
"""Proxy root WebSocket connections to the auth backend."""
await proxy_auth_websocket(request, ws)
@bp.route( @bp.route(
@@ -305,20 +362,10 @@ bp = Blueprint("sso", url_prefix="/auth")
) )
async def auth_proxy(request, path=""): async def auth_proxy(request, path=""):
"""Proxy all auth requests to the auth backend.""" """Proxy all auth requests to the auth backend."""
if _is_websocket_request(request):
await _handle_websocket_upgrade(request)
from sanic import empty
return empty()
return await proxy_auth_request(request) return await proxy_auth_request(request)
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) @bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def auth_proxy_root(request): async def auth_proxy_root(request):
"""Proxy root auth requests to the auth backend.""" """Proxy root auth requests to the auth backend."""
if _is_websocket_request(request):
await _handle_websocket_upgrade(request)
from sanic import empty
return empty()
return await proxy_auth_request(request) return await proxy_auth_request(request)
+26 -1
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):
@@ -22,11 +24,13 @@ def jres(data, **kwargs):
async def handle_sanic_exception(request, e): async def handle_sanic_exception(request, e):
context, code = {}, 500 context, code = {}, 500
headers = None
message = str(e) message = str(e)
if isinstance(e, SanicException): if isinstance(e, SanicException):
context = e.context or {} context = e.context or {}
code = e.status_code code = e.status_code
if not message or not request.app.debug and code == 500: headers = getattr(e, "headers", None)
if not message or (not request.app.debug and code == 500):
message = "Internal Server Error" message = "Internal Server Error"
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
if code == 500: if code == 500:
@@ -39,6 +43,7 @@ async def handle_sanic_exception(request, e):
return jres( return jres(
response_data, response_data,
status=code, status=code,
headers=headers,
) )
# Redirections flash the error message via cookies # Redirections flash the error message via cookies
if "redirect" in context: if "redirect" in context:
@@ -54,6 +59,11 @@ 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)
close_extra = None
try: try:
await auth.verify(request) await auth.verify(request)
await handler(request, ws, *args, **kwargs) await handler(request, ws, *args, **kwargs)
@@ -66,6 +76,21 @@ def websocket_wrapper(handler):
await asend(ws, ErrorMsg({"code": code, "message": message, **context})) await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
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}")
close_extra = f"{code} {message}"
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, extra=close_extra)
return wrapper return wrapper
+1 -1
View File
@@ -40,7 +40,7 @@ class AsyncLink:
async def stop(self): async def stop(self):
"""Stop worker and clean up.""" """Stop worker and clean up."""
while not self.queue.empty(): while not self.queue.empty():
command, future = self.queue.get_nowait() _command, future = self.queue.get_nowait()
if not future.done(): if not future.done():
future.set_exception(Exception("AsyncLink stopped")) future.set_exception(Exception("AsyncLink stopped"))
self.queue.task_done() self.queue.task_done()
+9 -7
View File
@@ -1,5 +1,5 @@
from collections.abc import Callable
from time import monotonic from time import monotonic
from typing import Callable
class LRUCache: class LRUCache:
@@ -7,22 +7,22 @@ class LRUCache:
LRUCache is a least-recently-used (LRU) cache with expiry time. LRUCache is a least-recently-used (LRU) cache with expiry time.
Attributes: Attributes:
open (callable): Function to open a new handle. opener (callable): Function to open a new handle.
capacity (int): Max number of items in the cache. capacity (int): Max number of items in the cache.
maxage (float): Max age for items in cache in seconds. maxage (float): Max age for items in cache in seconds.
cache (list): Internal list storing the cache items. cache (list): Internal list storing the cache items.
""" """
def __init__(self, open: Callable, *, capacity: int, maxage: float): def __init__(self, opener: Callable, *, capacity: int, maxage: float):
""" """
Initialize LRUCache. Initialize LRUCache.
Args: Args:
open (callable): Function to open a new handle. opener (callable): Function to open a new handle.
capacity (int): Maximum capacity of the cache. capacity (int): Maximum capacity of the cache.
maxage (float): Max age for items in cache in seconds. maxage (float): Max age for items in cache in seconds.
""" """
self.open = open self.opener = opener
self.capacity = capacity self.capacity = capacity
self.maxage = maxage self.maxage = maxage
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
@@ -47,7 +47,7 @@ class LRUCache:
self.cache.pop(i) self.cache.pop(i)
break break
else: else:
f = self.open(key) f = self.opener(key)
# Add/restore to end of cache # Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic())) self.cache.insert(0, (key, f, monotonic()))
self.expire_items() self.expire_items()
@@ -58,7 +58,9 @@ class LRUCache:
Expire items that are either too old or exceed cache capacity. Expire items that are either too old or exceed cache capacity.
""" """
ts = monotonic() - self.maxage ts = monotonic() - self.maxage
while len(self.cache) > self.capacity or self.cache and self.cache[-1][2] < ts: while len(self.cache) > self.capacity or (
self.cache and self.cache[-1][2] < ts
):
self.cache.pop()[1].close() self.cache.pop()[1].close()
def close(self): def close(self):
+1028 -53
View File
File diff suppressed because it is too large Load Diff
+449 -78
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
@@ -113,8 +141,29 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
state = State() state = State()
rootpath: Path = None # type: ignore rootpath: Path | None = None
quit = threading.Event() stop_event = 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 raw_path in paths:
path = PurePosixPath(raw_path) if isinstance(raw_path, str) else raw_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,21 +174,27 @@ 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:
return [entry] return [entry]
# Walk all entries of the directory # Walk all entries of the directory
ret: list[FileEntry] = [...] # type: ignore ret: list[FileEntry] = [...] # type: ignore[assignment]
li = [] li = []
for f in path.iterdir(): for f in path.iterdir():
if quit.is_set(): if stop_event.is_set():
raise SystemExit("quit") raise SystemExit("quit")
if f.name.startswith("."): if f.name.startswith("."):
continue # No dotfiles continue # No dotfiles
@@ -158,8 +213,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 +260,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,26 +389,262 @@ 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."""
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]
if 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 import inotify.adapters
modified_flags = ( modified_flags = frozenset(
(
"IN_CREATE", "IN_CREATE",
"IN_DELETE", "IN_DELETE",
"IN_DELETE_SELF", "IN_DELETE_SELF",
@@ -354,99 +653,171 @@ def watcher_inotify(loop):
"IN_MOVED_FROM", "IN_MOVED_FROM",
"IN_MOVED_TO", "IN_MOVED_TO",
) )
while not quit.is_set(): )
i = inotify.adapters.InotifyTree(rootpath.as_posix())
while not stop_event.is_set():
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
def add_dirty(
path: PurePosixPath,
source: str,
dirty_paths=dirty_paths,
) -> bool:
"""Add path to dirty set. Returns True if added, False if redundant."""
nonlocal first_event_time, last_event_time
# Check if already covered by an existing dirty path
for existing in dirty_paths:
if path == existing or (
len(path.parts) > len(existing.parts)
and path.parts[: len(existing.parts)] == existing.parts
):
# Count the event even if skipped
dirty_paths[existing][source] = (
dirty_paths[existing].get(source, 0) + 1
)
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 stop_event.is_set():
now = time.monotonic()
# Full refresh every 300s
if now >= trefresh:
break break
# Disk usage update
if t >= tspace: # Disk usage update every 5s
tspace = time.monotonic() + 5.0 if now >= tspace:
tspace = now + 5.0
update_space(loop) update_space(loop)
# Inotify events, update the tree
dirty = False # Check if we should flush pending changes
rootmod = state.root[:] should_flush = False
for event in i.event_gen(yield_nones=False, timeout_s=0.1): if dirty_paths:
assert event time_since_last = now - last_event_time if last_event_time else 0
if quit.is_set(): time_since_first = now - first_event_time if first_event_time else 0
return if (
interesting = any(f in modified_flags for f in event[1]) time_since_last >= DEBOUNCE_DELAY
if interesting: or time_since_first >= DEBOUNCE_MAX
# Update modified path ):
path = PurePosixPath(event[2]) / event[3] 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:
rel_path = path.relative_to(rootpath) update_msg = format_update(state.root, new_root)
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: with state.lock:
broadcast(update, loop) broadcast(update_msg, loop)
state.root = rootmod state.root = new_root
except Exception: except Exception:
logger.exception( logger.exception("format_update failed; full rescan")
"format_update failed; falling back to full rescan"
)
# Fallback: full rescan and try diff again; last resort send full root
try: try:
fresh = walk(PurePosixPath()) fresh = walk(PurePosixPath())
try: path_index = PathIndex(fresh)
update = format_update(state.root, 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 stop_event.is_set():
return
if not (modified_flags & set(event[1])):
continue
def watcher_poll(loop): # Extract relative path
"""Polling version of the watcher thread.""" path = PurePosixPath(event[2]) / event[3]
while not quit.is_set(): try:
t0 = time.perf_counter() rel_path = path.relative_to(rootpath)
update_root(loop) except ValueError:
update_space(loop) continue
dur = time.perf_counter() - t0
if dur > 1.0: # Skip dotfiles
logger.debug(f"Reading the full file list took {dur:.1f}s") if any(part.startswith(".") for part in rel_path.parts):
quit.wait(0.1 + 8 * dur) 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 start(app): def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll, target=watcher,
args=[app.loop], args=[app.loop],
# Descriptive name for system monitoring # Descriptive name for system monitoring
name=f"cista-watcher {rootpath}", name=f"cista-watcher {rootpath}",
@@ -455,5 +826,5 @@ def start(app):
def stop(app): def stop(app):
quit.set() stop_event.set()
app.ctx.watcher.join() app.ctx.watcher.join()
+69
View File
@@ -0,0 +1,69 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"ignore": ["node_modules", "dist", "coverage", "components.d.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 88
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noInferrableTypes": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"noUselessElse": "off",
"useExponentiationOperator": "off",
"useSingleVarDeclarator": "off",
"useTemplate": "off",
"useConst": "off",
"useImportType": "off"
},
"suspicious": {
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noMisleadingCharacterClass": "off"
},
"complexity": {
"noBannedTypes": "off",
"useOptionalChain": "off"
},
"correctness": {
"noSwitchDeclarations": "off"
},
"a11y": {
"useGenericFontNames": "off"
}
}
},
"overrides": [
{
"include": ["**/*.d.ts"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
},
"complexity": {
"noBannedTypes": "off"
}
}
}
}
],
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "asNeeded"
}
}
}
+5 -17
View File
@@ -9,8 +9,10 @@
"test:unit": "vitest", "test:unit": "vitest",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "biome lint .",
"format": "prettier --write src/" "format": "biome format --write .",
"format:check": "biome format --check .",
"check": "biome check ."
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -30,33 +32,19 @@
"vue-router": "^5.0.1" "vue-router": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.15.0", "@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"babel-eslint": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"prettier": "^3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 88
} }
} }
+141 -35
View File
@@ -7,36 +7,38 @@
</div> </div>
<SettingsModal /> <SettingsModal />
<UserManagementModal /> <UserManagementModal />
<UserTokensModal />
<AccessDeniedModal /> <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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue' import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { RouterView } from 'vue-router'
import { computed } from 'vue'
import Router from '@/router/index' import Router from '@/router/index'
import type { SortOrder } from './utils/docsort' import { computed } from 'vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
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 UserTokensModal from './components/UserTokensModal.vue'
import type { SortOrder } from './utils/docsort'
interface Path { interface Path {
path: string path: string
@@ -55,7 +57,10 @@ const path: ComputedRef<Path> = computed(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage' document.title =
path.value.path.replace(/\/$/, '').split('/').pop() ||
store.server.name ||
'Cista Storage'
}) })
onMounted(loadSession) onMounted(loadSession)
onMounted(watchConnect) onMounted(watchConnect)
@@ -63,6 +68,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) {
@@ -76,6 +82,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' ||
@@ -91,23 +104,41 @@ 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 = ''
store.clearToast() store.clearToast()
headerMain.value!.closeSearch(event) headerMain.value!.clearSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} } else if (!input && keyup && event.key === 'Backspace') {
else if (!input && keyup && event.key === 'Backspace') {
Router.back() Router.back()
} }
// Select all (toggle); keydown to precede and prevent builtin // Select all (toggle); keydown to precede and prevent builtin
@@ -122,20 +153,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if ( else if (
!input && !input &&
keyup && keyup &&
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3') (event.code === 'Backquote' ||
event.key === '1' ||
event.key === '2' ||
event.key === '3')
) { ) {
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder) store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
} }
// Rename // Rename
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) { else if (
!input &&
c &&
keyup &&
!event.ctrlKey &&
(event.key === 'F2' || event.key === 'r')
) {
fileExplorer.cursorRename() fileExplorer.cursorRename()
} }
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey // Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (!input && c && event.code === 'Space') { else if (!input && c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey) if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
fileExplorer.cursorSelect() } else return
}
else return
/// We are handling this! /// We are handling this!
event.preventDefault() event.preventDefault()
if (timer) { if (timer) {
@@ -143,26 +181,79 @@ 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 = () =>
case 'left': f = () => fileExplorer.left(event); break (
case 'right': f = () => fileExplorer.right(event); break document.querySelector('.headermain input[type="search"]') as HTMLElement
)?.focus()
const focusBreadcrumb = () =>
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
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
} }
if (f && !keyup) { } 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) {
// 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()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr) timer = setTimeout(() => {
timer = setInterval(f, tr)
}, t0 - tr)
} }
} }
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>
@@ -189,4 +280,19 @@ export type { Path }
background: #555; background: #555;
color: #fff; 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> </style>
+23 -72
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,43 +44,17 @@
--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 {
header .breadcrumb { align-self: stretch; } order: 1;
header .action-button { }
width: 2em; header .breadcrumb {
height: 2em; align-self: stretch;
} }
} }
@media print { @media print {
@@ -103,7 +78,7 @@
max-width: none !important; max-width: none !important;
} }
.breadcrumb > a::after { .breadcrumb > a::after {
content: '/'; content: "/";
} }
.breadcrumb svg { .breadcrumb svg {
fill: black !important; fill: black !important;
@@ -130,7 +105,8 @@
video::-webkit-media-controls { video::-webkit-media-controls {
display: none; display: none;
} }
tr, figure { tr,
figure {
page-break-inside: avoid; page-break-inside: avoid;
} }
.selection { .selection {
@@ -163,7 +139,7 @@ main {
body { body {
background-color: var(--primary-background); background-color: var(--primary-background);
font-size: 1rem; font-size: 1rem;
font-family: 'Roboto'; font-family: "Roboto";
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */ /* Prevent any scrolling on body */
@@ -174,7 +150,7 @@ body {
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
font-family: 'Roboto Mono'; font-family: "Roboto Mono";
} }
header { header {
flex: 0 0 auto; flex: 0 0 auto;
@@ -238,45 +214,20 @@ header nav.headermain {
position: relative; position: relative;
z-index: 100; z-index: 100;
} }
.spacer { flex-grow: 1 } .spacer {
.smallgap { flex-shrink: 1; width: 2em } flex-grow: 1;
}
.smallgap {
flex-shrink: 1;
width: 2em;
}
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
position: absolute;
pointer-events: none;
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;
}
@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;
}
+102 -54
View File
@@ -60,78 +60,126 @@ import Zoomout from './zoomout.svg'
// Named exports for direct imports // Named exports for direct imports
export { export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV, AddFile,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross, AddFolder,
Disk, Download, Exclamation, Eye, Find, Fullscreen, Arrow,
Github, Home, Info, Link, Logo, Loop, Menu, ArrowsH,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous, ArrowsV,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip, Check,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow, Code,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow, Cog,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap, Copy,
Zoomin, Zoomout 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) // Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = { export const icons = {
'add-file': AddFile, 'add-file': AddFile,
'add-folder': AddFolder, 'add-folder': AddFolder,
'arrow': Arrow, arrow: Arrow,
'arrows-h': ArrowsH, 'arrows-h': ArrowsH,
'arrows-v': ArrowsV, 'arrows-v': ArrowsV,
'check': Check, check: Check,
'code': Code, code: Code,
'cog': Cog, cog: Cog,
'copy': Copy, copy: Copy,
'create-file': CreateFile, 'create-file': CreateFile,
'create-folder': CreateFolder, 'create-folder': CreateFolder,
'cross': Cross, cross: Cross,
'disk': Disk, disk: Disk,
'download': Download, download: Download,
'exclamation': Exclamation, exclamation: Exclamation,
'eye': Eye, eye: Eye,
'find': Find, find: Find,
'fullscreen': Fullscreen, fullscreen: Fullscreen,
'github': Github, github: Github,
'home': Home, home: Home,
'info': Info, info: Info,
'link': Link, link: Link,
'logo': Logo, logo: Logo,
'loop': Loop, loop: Loop,
'menu': Menu, menu: Menu,
'next': Next, next: Next,
'open': Open, open: Open,
'paste': Paste, paste: Paste,
'pause': Pause, pause: Pause,
'pencil': Pencil, pencil: Pencil,
'play': Play, play: Play,
'plus': Plus, plus: Plus,
'previous': Previous, previous: Previous,
'reload': Reload, reload: Reload,
'rename': Rename, rename: Rename,
'scissors': Scissors, scissors: Scissors,
'shuffle': Shuffle, shuffle: Shuffle,
'signin': Signin, signin: Signin,
'signout': Signout, signout: Signout,
'skip': Skip, skip: Skip,
'spinner': Spinner, spinner: Spinner,
'stop': Stop, stop: Stop,
'trash': Trash, trash: Trash,
'triangle': Triangle, triangle: Triangle,
'unfullscreen': Unfullscreen, unfullscreen: Unfullscreen,
'up-arrow': UpArrow, 'up-arrow': UpArrow,
'upload-cloud': UploadCloud, 'upload-cloud': UploadCloud,
'user-cog': UserCog, 'user-cog': UserCog,
'user': User, user: User,
'volume-high': VolumeHigh, 'volume-high': VolumeHigh,
'volume-low': VolumeLow, 'volume-low': VolumeLow,
'volume-medium': VolumeMedium, 'volume-medium': VolumeMedium,
'volume-mute': VolumeMute, 'volume-mute': VolumeMute,
'window-cross': WindowCross, 'window-cross': WindowCross,
'window': Window, window: Window,
'wordwrap': Wordwrap, wordwrap: Wordwrap,
'zoomin': Zoomin, zoomin: Zoomin,
'zoomout': Zoomout, zoomout: Zoomout
} as const } as const
export type IconName = keyof typeof icons export type IconName = keyof typeof icons
+32 -11
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,38 @@
: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' import { Home } from '@/assets/svg'
import { exists } from '@/utils/fileutil'
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 CursorTooltip from './CursorTooltip.vue'
const home = Home 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) => {
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home if (el) links[index] = el
}
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>
@@ -50,7 +67,8 @@ const props = defineProps<{
const longest = ref<Array<string>>([]) const longest = ref<Array<string>>([])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined const isCurrent = (index: number) =>
index == props.path.length ? 'location' : undefined
const focusCurrent = () => { const focusCurrent = () => {
nextTick(() => { nextTick(() => {
@@ -67,7 +85,10 @@ const navigate = (index: number) => {
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)
}
// Moving along breadcrumbs doesn't create new history // Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u) else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs) // Nornal navigation from elsewhere (e.g. search result breadcrumbs)
@@ -87,8 +108,7 @@ watchEffect(() => {
if (!same) longest.value = props.path if (!same) longest.value = props.path
else if (props.path.length > longcut.length) { else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length)) longest.value = longcut.concat(props.path.slice(longcut.length))
} } else {
else {
// Prune deleted folders from longest // Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) { for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) { if (!exists(longest.value.slice(0, i + 1))) {
@@ -98,7 +118,8 @@ watchEffect(() => {
} }
} }
// If needed, focus primary navigation to new location // If needed, focus primary navigation to new location
if (props.primary) nextTick(() => { if (props.primary)
nextTick(() => {
const act = document.activeElement as HTMLElement const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent() if (!act || [...links, document.body].includes(act)) focusCurrent()
}) })
+56 -19
View File
@@ -1,6 +1,6 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle"> <div v-if="visible" ref="tooltipEl" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot> <slot></slot>
</div> </div>
</Teleport> </Teleport>
@@ -10,12 +10,12 @@
// Global activation state - shared across all instances // Global activation state - shared across all instances
let globalActive = false let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
// Track if we've seen real mouse movement (not touch-simulated) // Track recent touch to suppress touch-triggered mouse events
let hasRealMouse = false let lastTouchTime = 0
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{ const props = defineProps<{
text: string text: string
@@ -25,6 +25,9 @@ const props = defineProps<{
const visible = ref(false) const visible = ref(false)
const mouseX = ref(0) const mouseX = ref(0)
const mouseY = ref(0) const mouseY = ref(0)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const tooltipEl = ref<HTMLElement | null>(null)
let settleTimer: ReturnType<typeof setTimeout> | null = null let settleTimer: ReturnType<typeof setTimeout> | null = null
let lastMoveX = 0 let lastMoveX = 0
let lastMoveY = 0 let lastMoveY = 0
@@ -32,17 +35,51 @@ let lastMoveY = 0
// Movement threshold (pixels) - cursor must settle within this radius // Movement threshold (pixels) - cursor must settle within this radius
const SETTLE_THRESHOLD = 8 const SETTLE_THRESHOLD = 8
const tooltipStyle = computed(() => ({ const tooltipStyle = computed(() => {
left: `${mouseX.value}px`, // Constrain to viewport
top: `${mouseY.value}px`, const pad = 8
})) let x = mouseX.value
let y = mouseY.value
// Check if the device likely has a real mouse (fine pointer) // Only constrain if we've measured the tooltip
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches 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 = () => { const showTooltip = () => {
visible.value = true visible.value = true
globalActive = true globalActive = true
// Measure tooltip after it renders
requestAnimationFrame(() => {
if (tooltipEl.value) {
tooltipWidth.value = tooltipEl.value.offsetWidth
tooltipHeight.value = tooltipEl.value.offsetHeight
}
})
} }
const scheduleTooltip = () => { const scheduleTooltip = () => {
@@ -56,8 +93,8 @@ const scheduleTooltip = () => {
} }
const startHover = (e: MouseEvent) => { const startHover = (e: MouseEvent) => {
// Ignore touch events (no fine pointer and no confirmed real mouse) // Ignore touch-simulated mouse events
if (!hasFinePointer() && !hasRealMouse) return if (isTouchEvent()) return
mouseX.value = e.clientX mouseX.value = e.clientX
mouseY.value = e.clientY mouseY.value = e.clientY
@@ -66,9 +103,8 @@ const startHover = (e: MouseEvent) => {
} }
const updatePosition = (e: MouseEvent) => { const updatePosition = (e: MouseEvent) => {
// Detect real mouse via movement (touch events don't generate continuous mousemove) // Ignore touch-simulated mouse events
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true if (isTouchEvent()) return
if (!hasFinePointer() && !hasRealMouse) return
mouseX.value = e.clientX mouseX.value = e.clientX
mouseY.value = e.clientY mouseY.value = e.clientY
@@ -122,10 +158,11 @@ defineExpose({
z-index: 10000; z-index: 10000;
padding: .5rem 1rem; padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0; border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color); box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
background-color: var(--accent-color); background: rgba(0, 0, 0, 0.3);
color: var(--primary-color); backdrop-filter: blur(4px);
white-space: nowrap; -webkit-backdrop-filter: blur(4px);
color: #fff;
pointer-events: none; pointer-events: none;
font-size: 1rem; font-size: 1rem;
} }
+439
View File
@@ -0,0 +1,439 @@
<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 { useMainStore } from '@/stores/main'
import { computed, onMounted, onUnmounted, ref } from 'vue'
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.1) 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>
+23 -24
View File
@@ -1,13 +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 { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { zipName } from '@/utils/fileutil' import { zipName } from '@/utils/fileutil'
import { reactive } from 'vue';
const store = useMainStore() const store = useMainStore()
@@ -25,9 +24,9 @@ const status_init = {
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 0, filepos: 0,
status: 'idle', status: 'idle'
} }
store.dprogress = {...status_init} store.dprogress = { ...status_init }
setInterval(() => { setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) { if (Date.now() - store.dprogress.tlast > 3000) {
// Reset // Reset
@@ -35,8 +34,8 @@ setInterval(() => {
store.dprogress.statdur = 1 store.dprogress.statdur = 1
} else { } else {
// Running average by decay // Running average by decay
store.dprogress.statbytes *= .9 store.dprogress.statbytes *= 0.9
store.dprogress.statdur *= .9 store.dprogress.statdur *= 0.9
} }
}, 100) }, 100)
const statReset = () => { const statReset = () => {
@@ -48,7 +47,6 @@ const cancelDownloads = () => {
location.reload() // FIXME location.reload() // FIXME
} }
const linkdl = (href: string) => { const linkdl = (href: string) => {
const a = document.createElement('a') const a = document.createElement('a')
a.href = href a.href = href
@@ -131,24 +129,30 @@ 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.showToast('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.showToast(`Downloading ${files[0]![0].split('/').pop()}`) 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({
@@ -157,20 +161,15 @@ const download = async () => {
}) })
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 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()
} }
</script> </script>
<style scoped> <style scoped>
+4 -4
View File
@@ -3,7 +3,7 @@
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/> <component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p> <p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p> <p v-else-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p> <p v-else-if="store.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>
@@ -11,15 +11,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { Cog } from '@/assets/svg' import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
const cog = Cog const cog = Cog
const store = useMainStore() const store = useMainStore()
const props = defineProps<{ const props = defineProps<{
path: string[], path: string[]
documents: Document[], documents: Document[]
}>() }>()
</script> </script>
+195 -70
View File
@@ -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)"
> >
@@ -72,14 +72,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import { useMainStore } from '@/stores/main'
import { connect, controlUrl } from '@/repositories/WS'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useRouter } from 'vue-router'
import FileRenameInput from './FileRenameInput.vue'
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -87,35 +95,44 @@ const props = defineProps<{
}>() }>()
const store = useMainStore() const store = useMainStore()
const router = useRouter() const router = useRouter()
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
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
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch(
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
} }
defineExpose({ defineExpose({
newFolder() { newFolder() {
console.log("New folder") console.log('New folder')
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
loc: loc.value, loc: loc.value,
@@ -124,6 +141,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 +152,19 @@ 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
}, },
@@ -147,11 +178,22 @@ defineExpose({
} }
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, up(ev: KeyboardEvent) {
down(ev: KeyboardEvent) { this.cursorMove(1, ev) }, this.cursorMove(-1, ev)
left(ev: KeyboardEvent) { router.back() }, },
down(ev: KeyboardEvent) {
this.cursorMove(1, ev)
},
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()
}, },
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
@@ -165,8 +207,9 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index = store.cursor
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length ? docs.findIndex(doc => doc.key === store.cursor)
: docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? '' store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : '' const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
@@ -184,14 +227,23 @@ defineExpose({
scrolltr = tr scrolltr = tr
if (!scrolltimer) { if (!scrolltimer) {
scrolltimer = setTimeout(() => { scrolltimer = setTimeout(() => {
if (scrolltr) if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
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 +261,7 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
@@ -220,38 +272,31 @@ const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000) nowkey.value = Math.floor(Date.now() / 1000)
} }
onMounted(() => { onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000) updateModified()
modifiedTimer = setInterval(updateModified, 1000)
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()
} }
}) })
onUnmounted(() => { clearInterval(modifiedTimer) }) onUnmounted(() => {
const mkdir = (doc: Doc, name: string) => { clearInterval(modifiedTimer)
const control = connect(controlUrl, { })
open() { const mkdir = async (doc: Doc, name: string) => {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
// 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 path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter)
} catch (err) {
console.error('Mkdir failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
}
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
const docs = props.documents const docs = props.documents
@@ -290,13 +335,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 = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
try {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
store.showToast(`🗑️ Deleted ${doc.name}`)
} catch (err) {
console.error('Delete failed', err)
store.unhideDoc(path)
store.showToast(err instanceof Error ? err.message : 'Delete failed')
}
}
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 +520,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;
} }
+1 -1
View File
@@ -13,7 +13,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue' import { nextTick, onMounted, ref } from 'vue'
const input = ref<HTMLInputElement | null>(null) const input = ref<HTMLInputElement | null>(null)
const name = ref('') const name = ref('')
+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 { formatSize } from '@/utils'
import { computed, ref } from 'vue'
const sizeClass = computed(() => { import CursorTooltip from './CursorTooltip.vue'
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]! import SparseIndicator from './SparseIndicator.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 }
+198 -81
View File
@@ -9,13 +9,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS' import { useMainStore } from '@/stores/main'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort' import type { SortOrder } from '@/utils/docsort'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -23,38 +31,51 @@ const props = defineProps<{
}>() }>()
const store = useMainStore() const store = useMainStore()
const router = useRouter() const router = useRouter()
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null } const exit = () => {
const rename = (doc: Doc, newName: string) => { editing.value = null
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
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
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch(
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columnCount = ref(1) const columnCount = ref(1)
const updateColumns = () => { const updateColumns = () => {
if (!gallery.value) return if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
' '
).length
} }
const columns = computed(() => columnCount.value) const columns = computed(() => columnCount.value)
defineExpose({ defineExpose({
@@ -67,6 +88,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
}, },
@@ -81,6 +103,19 @@ 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
}, },
@@ -94,10 +129,18 @@ defineExpose({
} }
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) }, up(ev: KeyboardEvent) {
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) }, this.cursorMove(-columns.value, ev)
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, },
right(ev: KeyboardEvent) { this.cursorMove(1, ev) }, down(ev: KeyboardEvent) {
this.cursorMove(columns.value, ev)
},
left(ev: KeyboardEvent) {
this.cursorMove(-1, ev)
},
right(ev: KeyboardEvent) {
this.cursorMove(1, ev)
},
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation) // Move cursor up or down (keyboard navigation)
@@ -109,11 +152,10 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down) // Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === "ArrowLeft") return if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === "ArrowRight") return if (ev && index === N - 1 && ev.key === 'ArrowRight') return
// Calculate new position // Calculate new position
let moveto let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1 if (index === N) moveto = d > 0 ? 0 : N - 1
@@ -138,14 +180,23 @@ defineExpose({
scrolltr = tr scrolltr = tr
if (!scrolltimer) { if (!scrolltimer) {
scrolltimer = setTimeout(() => { scrolltimer = setTimeout(() => {
if (scrolltr) if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
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()
@@ -156,12 +207,17 @@ watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key if (editing.value) store.cursor = editing.value.key
if (store.cursor) { if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null const a = document.querySelector(
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) } `#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) {
a.focus()
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
@@ -182,30 +238,20 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
}) })
const mkdir = (doc: Doc, name: string) => { const mkdir = async (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
// 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 path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter)
} catch (err) {
console.error('Mkdir failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
}
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
const docs = props.documents const docs = props.documents
@@ -213,18 +259,6 @@ const showFolderBreadcrumb = (i: number) => {
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 (
@@ -246,13 +280,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 = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
try {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
store.showToast(`🗑️ Deleted ${doc.name}`)
} catch (err) {
console.error('Delete failed', err)
store.unhideDoc(path)
store.showToast(err instanceof Error ? err.message : 'Delete failed')
}
}
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>
+25 -10
View File
@@ -1,6 +1,6 @@
<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
@@ -18,7 +18,7 @@
</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>{{ 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>
@@ -26,32 +26,40 @@
<CursorTooltip ref="tooltip" :text="tooltipText"> <CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div> <div class="tooltip-name">{{ doc.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div> <div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
</CursorTooltip> </CursorTooltip>
</a> </a>
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue' import MediaPreview from '@/components/MediaPreview.vue'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
rename: (name: string) => void; rename: (name: string) => void
exit: () => void; exit: () => void
} }
const props = defineProps<{ const props = defineProps<{
doc: Doc, doc: Doc
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 tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = computed(() => props.doc.key) 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()
store.cursor = props.doc.key store.cursor = props.doc.key
@@ -66,6 +74,13 @@ const onclick = (ev: Event) => {
.tooltip-details { .tooltip-details {
text-align: center; 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;
+211 -54
View File
@@ -3,82 +3,109 @@
<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 v-if="showSortHints" class="sort-hints">
<span class="sort-label">Order</span>
<span class="keycap">1</span>
<span class="keycap">2</span>
<span class="keycap">3</span>
</div>
<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 { resumeWatching } from '@/repositories/WS'
import router from '@/router'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth' import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia' import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS' import { computed, onMounted, onUnmounted, ref } from 'vue'
import router from '@/router'; import DiskSpace from './DiskSpace.vue'
const store = useMainStore() const store = useMainStore()
const ssoStore = useSsoAuthStore() const ssoStore = useSsoAuthStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>() const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>() const textInputFocused = ref(false)
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
}>() }>()
const closeSearch = (ev: Event) => { const isInputElement = (el: Element | null): boolean => {
if (!showSearchInput.value) return // Already closing if (!el || !(el instanceof HTMLElement)) return false
showSearchInput.value = false return el instanceof HTMLInputElement
}
const updateTextInputFocused = () => {
textInputFocused.value = isInputElement(document.activeElement)
}
const showSortHints = computed(() => !textInputFocused.value)
const clearSearch = (ev: Event) => {
const input = search.value
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 || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Start search immediately via store (worker handles it async) // Start search immediately via store (worker handles it async)
store.search(q, props.path.join('/')) store.search(q, loc)
// Update route in next frame to keep typing responsive // Cancel any pending route update
requestAnimationFrame(() => { if (pendingRouteUpdate !== null) {
if (!props.query && q) router.push(u) cancelAnimationFrame(pendingRouteUpdate)
else router.replace(u) }
// Schedule route update - will be cancelled if user types again
pendingRouteUpdate = requestAnimationFrame(() => {
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)
}) })
} }
const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value const toggleSearchInput = () => {
if (!showSearchInput.value) return closeSearch(ev) search.value?.focus()
nextTick(() => {
const input = search.value
if (input) input.focus()
})
} }
watchEffect(() => {
if (props.query) showSearchInput.value = true
})
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
@@ -87,41 +114,78 @@ const settingsMenu = (e: Event) => {
if (ssoStore.isExternalAuth && store.user.isLoggedIn) { if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ items.push({
label: '👤 ' + (store.user.username || 'User Account'), label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' } onClick: () => {
window.location.href = '/auth/'
}
}) })
} }
// Only show password change for non-SSO users // Only show password change for non-SSO users
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) { if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }}) items.push({
label: '🔑 Change Password',
onClick: () => {
store.dialog = 'settings'
}
})
}
if (store.user.isLoggedIn) {
items.push({
label: '🔑 API Tokens',
onClick: () => {
store.dialog = 'tokens'
}
})
} }
if (store.user.privileged) { if (store.user.privileged) {
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }}) items.push({
label: '⚙️ Admin Settings',
onClick: () => {
store.dialog = 'usermgmt'
}
})
} }
if (store.user.isLoggedIn) { if (store.user.isLoggedIn) {
items.push({ label: '🚪 Logout', onClick: () => store.logout() }) items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (store.server.public) { } else if (store.server.public) {
// Show login option only in public mode (non-public modes trigger auth automatically) // Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => { items.push({
label: '🔐 Login',
onClick: async () => {
try { try {
await showAuthIframe('/auth/restricted#theme=light') await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching() resumeWatching()
} catch (e) { } catch (e) {
console.log('Login cancelled') console.log('Login cancelled')
} }
}}) }
})
} }
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom, x: e.target.getBoundingClientRect().right,
items, // @ts-ignore
y: e.target.getBoundingClientRect().bottom,
items
}) })
} }
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
closeSearch, clearSearch
})
onMounted(() => {
updateTextInputFocused()
window.addEventListener('focusin', updateTextInputFocused)
window.addEventListener('focusout', updateTextInputFocused)
})
onUnmounted(() => {
window.removeEventListener('focusin', updateTextInputFocused)
window.removeEventListener('focusout', updateTextInputFocused)
}) })
</script> </script>
@@ -132,13 +196,106 @@ 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;
}
}
.sort-hints {
display: none;
align-items: center;
gap: 0.25em;
margin-left: 0.3em;
white-space: nowrap;
}
.sort-label {
margin-right: 0.2em;
font-family: system-ui, sans-serif;
font-size: 1em;
font-weight: 700;
color: #ccc;
}
.keycap {
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;
}
@media screen and (min-width: 800px) {
.sort-hints {
display: flex;
}
} }
</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>
+123 -21
View File
@@ -1,9 +1,13 @@
<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>
<div v-else-if=video() class="video-container"> <div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <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 class="play-overlay"><PlayIcon /></div>
</div> </div>
<div v-else-if=audio() class="audio icon"> <div v-else-if=audio() class="audio icon">
@@ -13,19 +17,23 @@
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span> <span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { computed, ref } from 'vue' import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { Play as PlayIcon } from '@/assets/svg' import { computed, ref } from 'vue'
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
@@ -37,11 +45,21 @@ 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
media.value.load() // Restore poster media.value.load() // Restore poster
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[] const medias = Array.from(document.querySelectorAll('video, audio')) as (
| HTMLAudioElement
| HTMLVideoElement
)[]
if (medias.length === 0) return if (medias.length === 0) return
let el: HTMLAudioElement | HTMLVideoElement | null = null let el: HTMLAudioElement | HTMLVideoElement | null = null
for (const i in medias) { for (const i in medias) {
@@ -51,7 +69,7 @@ const next = () => {
} }
} }
if (!el) return if (!el) return
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) { if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
// Fullscreen needs to use the current video element for the next video // Fullscreen needs to use the current video element for the next video
// because we are not allowed to fullscreen the next one. // because we are not allowed to fullscreen the next one.
// FIXME: Write our own player to avoid this problem... // FIXME: Write our own player to avoid this problem...
@@ -62,7 +80,9 @@ const next = () => {
return return
} }
if (!fscurrent) { if (!fscurrent) {
elem.addEventListener('fullscreenchange', ev => { elem.addEventListener(
'fullscreenchange',
ev => {
if (!fscurrent) return if (!fscurrent) return
// Restore the original video element and continue with the one that was playing // Restore the original video element and continue with the one that was playing
fscurrent.currentTime = elem.currentTime fscurrent.currentTime = elem.currentTime
@@ -70,9 +90,11 @@ 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 }
)
} }
fscurrent = playing fscurrent = playing
elem.src = playing.src elem.src = playing.src
@@ -88,7 +110,10 @@ defineExpose({
if (!media.value) return false if (!media.value) return false
if (media.value.paused) { if (media.value.paused) {
media.value.play() media.value.play()
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) { for (const el of Array.from(document.querySelectorAll('video, audio')) as (
| HTMLAudioElement
| HTMLVideoElement
)[]) {
if (el === media.value) continue if (el === media.value) continue
el.pause() el.pause()
} }
@@ -97,18 +122,67 @@ defineExpose({
} }
return true return true
}, },
media, media
}) })
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 = () =>
const preview = () => ( ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) || const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
props.doc.size > 500000 && const preview = () =>
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext) [
) 'bmp',
'ico',
'tif',
'tiff',
'heic',
'heif',
'pdf',
'epub',
'mobi',
// Documents
'doc',
'dot',
'docx',
'docm',
'dotx',
'dotm',
'rtf',
'odt',
'ott',
'txt',
'md',
'mhtml',
'mht',
'html',
'htm',
'xml',
'wps',
'wri',
// Spreadsheets
'xls',
'xlsx',
'xlsm',
'xlsb',
'xltx',
'xltm',
'ods',
'ots',
'csv',
// Presentations
'ppt',
'pptx',
'pptm',
'pps',
'ppsx',
'pot',
'potx',
'odp',
'otp'
].includes(props.doc.ext) ||
(props.doc.size > 500000 &&
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext))
</script> </script>
<style scoped> <style scoped>
@@ -120,6 +194,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: '📁';
} }
@@ -175,9 +272,14 @@ img::before {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 50%; min-width: 50%;
min-height: 6em;
aspect-ratio: 16 / 9;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.video-container.pending {
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
}
.video-container video { .video-container video {
width: 100%; width: 100%;
height: 100%; height: 100%;
+4 -4
View File
@@ -13,9 +13,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { nextTick, ref, watchEffect } from 'vue'
const overlay = ref<HTMLDivElement | null>(null) const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null) const dialog = ref<HTMLDivElement | null>(null)
@@ -27,9 +27,9 @@ const close = () => {
} }
const props = defineProps<{ const props = defineProps<{
title: string, title: string
name: typeof store.dialog, name: typeof store.dialog
}>() }>()
const show = () => { const show = () => {
store.dialog = props.name store.dialog = props.name
+2 -3
View File
@@ -10,13 +10,12 @@
> >
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
}>() }>()
const store = useMainStore() const store = useMainStore()
</script> </script>
@@ -0,0 +1,262 @@
<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 />
<button
class="action-button"
title="Copy share link (Alt-click for read/write)"
@click="copyShareLink"
>share</button>
<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 { apiFetch } from '@/repositories/Client'
import type { ISimpleError } from '@/repositories/Client'
import { createShareToken } from '@/repositories/User'
import router from '@/router'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
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)
}
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// 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 = async (opName: string, dst?: string) => {
const sel = store.selectedFiles
const keys = sel.keys
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
try {
if (opName === 'rm') {
for (const path of paths) {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
}
} else if (opName === 'mv' || opName === 'cp') {
if (keys.length === 0) throw new Error('No selected files')
const dstUrl = dst ? filesUrl(dst) : '/files/'
const query = `${opName}=${keys.join('+')}`
const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
} else {
throw new Error(`Unsupported operation: ${opName}`)
}
store.selected.clear()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('REST file operation failed', opName, err)
store.error = message
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
}
}
const copyShareLink = async (ev: MouseEvent) => {
const mode: 'ro' | 'rw' = ev.altKey ? 'rw' : 'ro'
const sel = store.selectedFiles
const paths = sel.keys
.map(key => {
const doc = sel.docs[key]
if (!doc) return ''
if (doc.loc === '/' || !doc.loc) return doc.name
return `${doc.loc}/${doc.name}`
})
.filter(Boolean)
if (!paths.length) {
store.showToast('No selected files')
return
}
try {
const token = await createShareToken(paths, mode)
await navigator.clipboard.writeText(token.url)
store.showToast(
mode === 'rw' ? 'Copied read/write share link' : 'Copied share link'
)
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create share link')
}
}
</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>
+2 -2
View File
@@ -44,10 +44,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from 'vue'
import { changePassword } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { changePassword } from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { reactive, ref } from 'vue'
const confirmLoading = ref<boolean>(false) const confirmLoading = ref<boolean>(false)
const store = useMainStore() const store = useMainStore()
@@ -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 -8
View File
@@ -1,16 +1,30 @@
<template> <template>
<button class="action-button"> <button
class="action-button"
:tabindex="tabindex"
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>
<component :is="icons[name]" /> <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 { icons, type IconName } from '@/assets/svg' import { type IconName, icons } from '@/assets/svg'
import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
defineProps<{ const props = defineProps<{
name: IconName name: IconName
tooltip?: string
tabindex?: string | number
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = props.tooltip ?? ''
</script> </script>
<style> <style>
@@ -20,18 +34,29 @@ defineProps<{
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 {
+8 -5
View File
@@ -19,7 +19,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
defineEmits(['cancel']) defineEmits(['cancel'])
@@ -38,7 +38,7 @@ const props = defineProps<{
} }
}>() }>()
const percent = computed(() => props.status.xfer / props.status.total * 100) const percent = computed(() => (props.status.xfer / props.status.total) * 100)
const speed = computed(() => { const speed = computed(() => {
let s = props.status.statbytes / props.status.statdur / 1e3 let s = props.status.statbytes / props.status.statdur / 1e3
const tsince = (Date.now() - props.status.tlast) / 1e3 const tsince = (Date.now() - props.status.tlast) / 1e3
@@ -46,22 +46,25 @@ const speed = computed(() => {
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed" return s // "Current speed"
}) })
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled') const speeddisp = computed(() =>
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
)
</script> </script>
<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;
} }
+377 -84
View File
@@ -3,16 +3,19 @@
<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 { Doc } from '@/repositories/Document'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'; import { collator } from '@/utils'
import { onMounted, onUnmounted, reactive, ref } from 'vue' import { onMounted, onUnmounted, 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()
@@ -25,6 +28,22 @@ type CloudFile = {
cloudName: string cloudName: string
cloudPos: number cloudPos: number
} }
type UploadRange = {
name: string
size: number
start: number
end: number
}
type InflightBlock = {
name: string
start: number
end: number
startedAt: number
}
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
function pasteHandler(event: ClipboardEvent) { function pasteHandler(event: ClipboardEvent) {
const items = Array.from(event.clipboardData?.items ?? []) const items = Array.from(event.clipboardData?.items ?? [])
const infiles = [] as File[] const infiles = [] as File[]
@@ -42,7 +61,9 @@ function pasteHandler(event: ClipboardEvent) {
if (infiles.length || dirs.length) { if (infiles.length || dirs.length) {
event.preventDefault() event.preventDefault()
uploadFiles(infiles) uploadFiles(infiles)
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`) const base = props.path!.join('/')
for (const entry of dirs)
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
} }
} }
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => { const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
@@ -52,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
for (const entry of entries) { for (const entry of entries) {
const cloudName = `${loc}/${entry.name}` const cloudName = `${loc}/${entry.name}`
if (entry.isFile) { if (entry.isFile) {
const file = await new Promise(resolve => entry.file(resolve)) as File const file = (await new Promise(resolve => entry.file(resolve))) as File
cloudfiles.push({file, cloudName, cloudPos: 0}) cloudfiles.push({ file, cloudName, cloudPos: 0 })
} else if (entry.isDirectory) { } else if (entry.isDirectory) {
await pasteDirectory(entry, cloudName) await pasteDirectory(entry, cloudName)
} }
@@ -64,7 +85,9 @@ function uploadHandler(event: Event) {
event.preventDefault() event.preventDefault()
// @ts-ignore // @ts-ignore
const input = event.target as HTMLInputElement | null const input = event.target as HTMLInputElement | null
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[] const infiles = Array.from(
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
) as File[]
if (input) input.value = '' if (input) input.value = ''
if (infiles.length) uploadFiles(infiles) if (infiles.length) uploadFiles(infiles)
} }
@@ -72,24 +95,74 @@ 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 ? `${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.showToast("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)
@@ -97,7 +170,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
} }
const cancelUploads = () => { const cancelUploads = () => {
uploadRunId += 1
upqueue = [] upqueue = []
blockQueue = []
inflightBlocks.clear()
uploadedBytes.clear()
store.uprogress.status = 'idle'
statReset() statReset()
} }
@@ -115,30 +193,106 @@ const uprogress_init = {
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 0, filepos: 0,
status: 'idle', status: 'idle'
} }
store.uprogress = {...uprogress_init} store.uprogress = { ...uprogress_init }
// Track uploaded bytes for each file to handle out-of-order uploads
const uploadedBytes = new Map<string, Set<number>>()
const inflightBlocks = new Map<string, InflightBlock>()
let smoothedBlockMs = 1500
let lastProgressTick = Date.now()
let lastVisualUploaded = 0
const inflightKey = (name: string, start: number) => `${name}:${start}`
const completedUploadedBytes = (name: string, size: number) => {
const uploaded = uploadedBytes.get(name)
if (!uploaded) return 0
const blockSize = UPLOAD_BLOCK_SIZE
let total = 0
for (let i = 0; i < size; i += blockSize) {
if (uploaded.has(i)) total += Math.min(blockSize, size - i)
}
return total
}
const simulatedInflightBytes = (name: string, now: number) => {
let total = 0
for (const block of inflightBlocks.values()) {
if (block.name !== name) continue
const size = block.end - block.start
const elapsed = Math.max(0, now - block.startedAt)
const fraction = Math.min(0.98, elapsed / Math.max(200, smoothedBlockMs))
total += size * fraction
}
return total
}
const refreshProgress = (now: number) => {
const name = store.uprogress.filename
const size = store.uprogress.filesize
if (!name || !size) {
lastProgressTick = now
return 0
}
const completed = completedUploadedBytes(name, size)
const estimated = simulatedInflightBytes(name, now)
const visualUploaded = Math.min(size, Math.round(completed + estimated))
const delta = Math.max(0, visualUploaded - lastVisualUploaded)
const dt = Math.max(1, now - lastProgressTick)
store.uprogress.filepos = visualUploaded
store.uprogress.xfer = store.uprogress.filestart + visualUploaded
if (delta > 0) {
store.uprogress.statbytes += delta
store.uprogress.statdur += dt
store.uprogress.tlast = now
}
lastVisualUploaded = visualUploaded
lastProgressTick = now
return delta
}
setInterval(() => { setInterval(() => {
if (Date.now() - store.uprogress.tlast > 3000) { const now = Date.now()
// Reset const delta = refreshProgress(now)
if (delta > 0) return
if (now - store.uprogress.tlast > 3000) {
store.uprogress.statbytes = 0 store.uprogress.statbytes = 0
store.uprogress.statdur = 1 store.uprogress.statdur = 1
} else { } else {
// Running average by decay store.uprogress.statbytes *= 0.95
store.uprogress.statbytes *= .9 store.uprogress.statdur *= 0.95
store.uprogress.statdur *= .9
} }
}, 100) }, 100)
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
const statUpdate = ({ name, size, start, end }: UploadRange) => {
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()
store.uprogress.xfer = store.uprogress.filestart + end // Track which bytes have been uploaded (using start to end range)
store.uprogress.filepos = end if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
store.uprogress.statbytes += end - start const uploaded = uploadedBytes.get(name)!
store.uprogress.statdur += now - store.uprogress.tlast const blockSize = UPLOAD_BLOCK_SIZE
store.uprogress.tlast = now
// File finished? // Mark all bytes in this block as uploaded
if (end === size) { for (let i = start; i < end; i += blockSize) {
uploaded.add(i)
}
refreshProgress(Date.now())
// Check if file is fully uploaded by examining the block queue
const currentUpload = blockQueue[0]
if (!currentUpload) return
if (
currentUpload.file.cloudName === name &&
currentUpload.completed >= 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()
@@ -150,11 +304,15 @@ const statNextFile = () => {
store.uprogress.filepos = 0 store.uprogress.filepos = 0
store.uprogress.filesize = f.file.size store.uprogress.filesize = f.file.size
store.uprogress.filename = f.cloudName store.uprogress.filename = f.cloudName
lastVisualUploaded = 0
lastProgressTick = Date.now()
} }
const statReset = () => { const statReset = () => {
Object.assign(store.uprogress, uprogress_init) Object.assign(store.uprogress, uprogress_init)
store.uprogress.t0 = Date.now() store.uprogress.t0 = Date.now()
store.uprogress.tlast = store.uprogress.t0 + 1 store.uprogress.tlast = store.uprogress.t0 + 1
lastVisualUploaded = 0
lastProgressTick = store.uprogress.t0
} }
const statsAdd = (f: CloudFile[]) => { const statsAdd = (f: CloudFile[]) => {
if (store.uprogress.files.length === 0) statReset() if (store.uprogress.files.length === 0) statReset()
@@ -164,69 +322,204 @@ const statsAdd = (f: CloudFile[]) => {
statNextFile() statNextFile()
} }
let upqueue = [] as CloudFile[] let upqueue = [] as CloudFile[]
const MAX_PARALLEL_REQUESTS = 4
const RETRY_DELAY_MS = 400
// TODO: Rewrite as WebSocket class // Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
const WSCreate = async () => await new Promise<WebSocket>(resolve => { const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
const ws = connect(uploadUrl, { const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
open(ev: Event) { resolve(ws) }, const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
error(ev: Event) { const FINAL_BLOCKS_COUNT = 2
console.error('Upload socket error', ev)
store.error = 'Upload socket error' const fileSize = file.file.size
}, const blocks: { start: number; end: number }[] = []
message(ev: MessageEvent) {
const res = JSON.parse(ev!.data) if (fileSize >= MIN_SIZE_FOR_REORDER) {
if ('error' in res) { // File is large enough, prioritize final blocks
console.error('Upload socket error', res.error) const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
store.error = res.error.message
return // 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 })
} }
if (res.status === 'ack') {
statUpdate(res.req) // Add remaining blocks from beginning
} else console.log('Unknown upload response', res) for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
}, const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
}) blocks.push({ start, end })
// @ts-ignore
ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg))
// @ts-ignore
ws.sendData = async (data: any) => {
// Wait until the WS is ready to send another message
store.uprogress.status = "uploading"
await new Promise(resolve => {
const t = setInterval(() => {
if (ws.bufferedAmount > 1<<20) return
resolve(undefined)
clearInterval(t)
}, 1)
})
store.uprogress.status = "processing"
ws.send(data)
} }
}) } else {
const worker = async () => { // File is smaller, use sequential upload
const ws = await WSCreate() for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
while (upqueue.length) { const end = Math.min(start + BLOCK_SIZE, fileSize)
const f = upqueue[0]! blocks.push({ start, end })
const start = f.cloudPos
const end = Math.min(f.file.size, start + (1<<20))
const control = { name: f.cloudName, size: f.file.size, start, end }
const data = f.file.slice(start, end)
f.cloudPos = end
// Note: files may get modified during I/O
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
ws.sendMsg(control)
// @ts-ignore
await ws.sendData(data)
if (f.cloudPos === f.file.size) upqueue.shift()
} }
if (upqueue.length) startWorker() }
store.uprogress.status = "idle"
workerRunning = false return blocks
} }
let workerRunning: any = false
type BlockUpload = {
file: CloudFile
blocks: { start: number; end: number }[]
nextIndex: number
completed: number
runId: number
}
let blockQueue = [] as BlockUpload[]
let workerRunning = false
let uploadRunId = 0
const enqueuePendingUploads = () => {
while (upqueue.length) {
const file = upqueue.shift()!
const blocks = getUploadBlocks(file)
blockQueue.push({ file, blocks, nextIndex: 0, completed: 0, runId: uploadRunId })
}
}
const uploadUrlForFile = (cloudName: string) => {
const normalized = cloudName.replace(/^\/+/, '')
const encoded = normalized.split('/').map(encodeURIComponent).join('/')
return `/files/${encoded}`
}
const uploadBlock = async (
upload: BlockUpload,
block: { start: number; end: number }
) => {
const body = upload.file.file.slice(block.start, block.end)
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
const fallbackReq = {
name: upload.file.cloudName,
size: upload.file.file.size,
start: block.start,
end: block.end
}
let attempt = 0
while (true) {
attempt += 1
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
try {
const res = await fetch(uploadUrlForFile(upload.file.cloudName), {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Range': range
},
body
})
if (!res.ok) {
const message = await res.text().catch(() => '')
const retryable = res.status >= 500 || res.status === 408 || res.status === 429
if (!retryable) throw new Error(message || `HTTP ${res.status}`)
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
continue
}
const payload = await res.json().catch(() => null)
return payload?.status === 'ack' && payload.req ? payload.req : fallbackReq
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'Upload cancelled') throw err
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
if (attempt % 10 === 0) {
console.warn(`Upload retry ${attempt} for ${upload.file.cloudName}: ${message}`)
}
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
}
}
}
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
inflightBlocks.set(inflightKey(name, block.start), {
name,
start: block.start,
end: block.end,
startedAt: Date.now()
})
}
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
const key = inflightKey(name, block.start)
const info = inflightBlocks.get(key)
if (!info) return
const elapsed = Math.max(1, Date.now() - info.startedAt)
smoothedBlockMs = smoothedBlockMs * 0.85 + elapsed * 0.15
inflightBlocks.delete(key)
}
const worker = async (runId: number) => {
try {
while (runId === uploadRunId) {
enqueuePendingUploads()
if (!blockQueue.length) break
const upload = blockQueue[0]!
const inflight = new Set<Promise<void>>()
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
while (
runId === uploadRunId &&
upload.nextIndex < upload.blocks.length &&
inflight.size < MAX_PARALLEL_REQUESTS
) {
const block = upload.blocks[upload.nextIndex++]!
store.uprogress.status = 'uploading'
startInflightBlock(upload.file.cloudName, block)
let task: Promise<void>
task = uploadBlock(upload, block)
.then(req => {
finishInflightBlock(upload.file.cloudName, block)
upload.completed += 1
statUpdate(req)
})
.catch(err => {
finishInflightBlock(upload.file.cloudName, block)
throw err
})
.finally(() => {
inflight.delete(task)
})
inflight.add(task)
}
if (!inflight.size) break
await Promise.race(inflight)
}
if (runId !== uploadRunId) return
if (upload.completed >= upload.blocks.length) {
blockQueue.shift()
} else {
break
}
}
} catch (err: any) {
if (runId !== uploadRunId) return
console.error('Upload error', err)
store.error = err?.message || 'Upload failed'
uploadRunId += 1
upqueue = []
blockQueue = []
inflightBlocks.clear()
} finally {
store.uprogress.status = 'idle'
workerRunning = false
if (upqueue.length) startWorker()
}
}
const startWorker = () => { const startWorker = () => {
if (workerRunning === false) workerRunning = setTimeout(() => { if (workerRunning) return
workerRunning = true workerRunning = true
worker() const runId = uploadRunId
setTimeout(() => {
void worker(runId)
}, 0) }, 0)
} }
+92 -14
View File
@@ -3,6 +3,19 @@
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<div v-else> <div v-else>
<h3>Server Settings</h3> <h3>Server Settings</h3>
<div class="form-row">
<label for="serverName">Server name</label>
<div class="input-with-hint">
<input
type="text"
id="serverName"
v-model="serverSettings.name"
@input="debouncedUpdateServerName"
:placeholder="store.server.name"
/>
<small>Leave empty to use the share folder name</small>
</div>
</div>
<div class="form-row"> <div class="form-row">
<label for="publicAccess"> <label for="publicAccess">
<input <input
@@ -61,10 +74,18 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import {
createUser,
deleteUser,
getServerConfig,
listUsers,
updatePublic,
updateServerName,
updateUser
} from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { onMounted, reactive, ref, watch } from 'vue'
interface User { interface User {
username: string username: string
@@ -78,9 +99,12 @@ const users = ref<User[]>([])
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 = ''
success.value = '' success.value = ''
@@ -147,10 +171,13 @@ const renameUser = async (user: 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 {
success.value = '' success.value = ''
const result = await updateUser(user.username, { password: "" }) const result = await updateUser(user.username, { password: '' })
if (result.password) { if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}` success.value = `Password reset for ${user.username}. New password: ${result.password}`
} }
@@ -172,14 +199,17 @@ const deleteUserAction = async (username: string) => {
} }
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|Key): (.+)/)
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!'
// Hide password and button immediately after copying // Hide password/key and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!') const baseMessage = success.value.replace(
/(?:Password|New password|Key): .+/,
'Copied to clipboard!'
)
success.value = baseMessage success.value = baseMessage
// Hide the entire message after 3 seconds // Hide the entire message after 3 seconds
setTimeout(() => { setTimeout(() => {
@@ -206,23 +236,71 @@ const updateServerSettings = async () => {
} }
} }
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 || false serverSettings.public = store.server.public || false
serverSettings.name = ''
loading.value = false loading.value = false
}) })
// Load users when dialog opens (only in built-in auth mode) // Load users and config when dialog opens
watch(() => store.dialog, (newVal) => { watch(
if (newVal === 'usermgmt' && !store.server.paskia) { () => store.dialog,
newVal => {
if (newVal === 'usermgmt') {
loadServerConfig()
if (!store.server.paskia) {
loadUsers() loadUsers()
} }
}) }
}
)
watch(() => store.server.public, (newVal) => { watch(
() => store.server.public,
newVal => {
serverSettings.public = newVal || false serverSettings.public = newVal || false
}) }
)
</script> </script>
<style scoped> <style scoped>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */ /* Component-specific styles - most styling comes from ModalDialog.vue global styles */
.input-with-hint {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input-with-hint small {
color: #666;
font-size: 0.75rem;
}
</style> </style>
+267
View File
@@ -0,0 +1,267 @@
<template>
<ModalDialog name=tokens title="My API Tokens">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
<!-- Creation form -->
<div v-if="mode === 'creating'" class="create-form">
<label for="token-name">Token name (optional)</label>
<input
id="token-name"
v-model="newTokenName"
type="text"
placeholder="e.g. backup-script"
@keyup.enter="submitCreate"
ref="nameInput"
/>
<div class="form-actions">
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
<button @click="cancelCreate" class="button">Cancel</button>
</div>
</div>
<!-- Creation result -->
<div v-else-if="mode === 'created' && createdToken" class="created-result">
<p class="success-title"> Token created</p>
<p class="hint">Copy this URL it will not be shown again.</p>
<div class="url-box">
<code class="token-url">{{ createdToken.url }}</code>
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
</div>
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
<div class="form-actions">
<button @click="finishCreate" class="button primary">Done</button>
</div>
</div>
<!-- Token list -->
<div v-else>
<button @click="startCreate" class="button" title="Add new token"> Add Token</button>
<table v-if="tokens.length">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in tokens" :key="token.id">
<td>{{ token.name || 'Unnamed' }}</td>
<td>{{ formatDate(token.created) }}</td>
<td>
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty">You have no API tokens.</p>
</div>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import type { ISimpleError } from '@/repositories/Client'
import { createToken, deleteToken, listTokens } from '@/repositories/User'
import { useMainStore } from '@/stores/main'
import { nextTick, ref, watch } from 'vue'
interface Token {
id: string
username: string
sso_user_id: string
name: string
created: number
}
interface CreatedToken extends Token {
key: string
url: string
}
const store = useMainStore()
const loading = ref(true)
const tokens = ref<Token[]>([])
const mode = ref<'list' | 'creating' | 'created'>('list')
const newTokenName = ref('')
const creating = ref(false)
const createdToken = ref<CreatedToken | null>(null)
const copyButtonText = ref('📋')
const nameInput = ref<HTMLInputElement | null>(null)
const close = () => {
store.dialog = ''
resetCreate()
}
const resetCreate = () => {
mode.value = 'list'
newTokenName.value = ''
creating.value = false
createdToken.value = null
copyButtonText.value = '📋'
}
const loadTokens = async () => {
try {
loading.value = true
const data = await listTokens()
tokens.value = data.tokens
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to load tokens')
} finally {
loading.value = false
}
}
const startCreate = () => {
mode.value = 'creating'
nextTick(() => nameInput.value?.focus())
}
const cancelCreate = () => {
resetCreate()
}
const ensureFilesBaseUrl = (url: string) => {
const trimmed = url.replace(/\/+$/, '')
if (trimmed.endsWith('/files')) return trimmed
return `${trimmed}/files`
}
const submitCreate = async () => {
if (creating.value) return
creating.value = true
try {
const result = await createToken(newTokenName.value)
await loadTokens()
if (result.url) {
createdToken.value = {
...(result as CreatedToken),
url: ensureFilesBaseUrl((result as CreatedToken).url)
}
mode.value = 'created'
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create token')
mode.value = 'list'
} finally {
creating.value = false
}
}
const finishCreate = () => {
resetCreate()
}
const copyUrl = async () => {
if (!createdToken.value) return
await navigator.clipboard.writeText(createdToken.value.url)
copyButtonText.value = '✅ Copied!'
setTimeout(() => {
copyButtonText.value = '📋'
}, 2000)
}
const deleteTokenAction = async (tokenId: string) => {
if (!confirm('Revoke this token? It will no longer work.')) return
try {
await deleteToken(tokenId)
await loadTokens()
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to revoke token')
}
}
const formatDate = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
// Load tokens when dialog opens
watch(
() => store.dialog,
newVal => {
if (newVal === 'tokens') {
resetCreate()
loadTokens()
}
}
)
</script>
<style scoped>
.hint {
color: #666;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.empty {
color: #888;
font-style: italic;
margin: 1rem 0;
}
.create-form {
margin-bottom: 1rem;
}
.create-form label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #444;
}
.create-form input {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
margin-bottom: 0.5rem;
}
.create-form input:focus {
outline: none;
border-color: #f80;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.created-result {
margin-bottom: 1rem;
}
.success-title {
color: #080;
font-weight: bold;
margin: 0 0 0.5rem 0;
}
.url-box {
display: flex;
gap: 0.5rem;
align-items: center;
background: #f0f0f0;
padding: 0.75rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
.token-url {
flex: 1;
word-break: break-all;
font-size: 0.875rem;
color: #222;
}
.dialog-buttons {
margin-top: 1rem;
text-align: right;
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
import './assets/main.css' import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+1 -1
View File
@@ -1,4 +1,4 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia' import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
// Type for API error responses // Type for API error responses
interface ApiError { interface ApiError {
+98 -12
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate } from "@/utils" import { formatSize, formatUnixDate } from '@/utils'
export type FUID = string export type FUID = string
@@ -7,34 +7,57 @@ 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 {
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 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 */ /** @internal Use the name getter/setter instead */
public _name: string = "" public _name: string = ''
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
Object.assign(this, rest) Object.assign(this, rest)
if (name) this._name = name // Skip validation/haystack for bulk loading 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
} }
get sizedisp(): string { return formatSize(this.size) } get sizedisp(): string {
get modified(): string { return formatUnixDate(this.mtime) } 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 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
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23') return this.dir
? '/#/' + `${p}/`.replaceAll('#', '%23')
: `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
} }
get urlrouter(): string { get urlrouter(): string {
return this.url.replace(/^\/#/, '') return this.url.replace(/^\/#/, '')
@@ -42,16 +65,78 @@ export class Doc {
get img(): boolean { get img(): boolean {
// Folders cannot be images // Folders cannot be images
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
if (this.img) return true if (this.img) return true
// Not a comprehensive list, but good enough for now // Not a comprehensive list, but good enough for now
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext) return [
'mp4',
'mkv',
'webm',
'ogg',
'mp3',
'flac',
'aac',
'pdf',
// Documents
'doc',
'dot',
'docx',
'docm',
'dotx',
'dotm',
'rtf',
'odt',
'ott',
'txt',
'md',
'mhtml',
'mht',
'html',
'htm',
'xml',
'wps',
'wri',
// Spreadsheets
'xls',
'xlsx',
'xlsm',
'xlsb',
'xltx',
'xltm',
'ods',
'ots',
'csv',
// Presentations
'ppt',
'pptx',
'pptm',
'pps',
'ppsx',
'pot',
'potx',
'odp',
'otp'
].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 {
@@ -74,9 +159,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>]
+52 -3
View File
@@ -16,7 +16,11 @@ export async function logoutUser() {
return data return data
} }
export async function changePassword(username: string, passwordChange: string, password: string) { export async function changePassword(
username: string,
passwordChange: string,
password: string
) {
const data = await Client.post(url_password, { const data = await Client.post(url_password, {
username, username,
passwordChange, passwordChange,
@@ -32,7 +36,11 @@ export async function listUsers() {
return data return data
} }
export async function createUser(username: string, password?: string, privileged?: boolean) { export async function createUser(
username: string,
password?: string,
privileged?: boolean
) {
const data = await Client.post(url_users, { const data = await Client.post(url_users, {
username, username,
password, password,
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
return data return data
} }
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) { export async function updateUser(
username: string,
changes: { password?: string; privileged?: boolean }
) {
const data = await Client.put(`${url_users}/${username}`, changes) const data = await Client.put(`${url_users}/${username}`, changes)
return data return data
} }
@@ -55,3 +66,41 @@ export async function updatePublic(isPublic: boolean) {
const data = await Client.put('/api/config/public', { public: isPublic }) 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 }
}
export const url_tokens = '/api/tokens'
export async function listTokens() {
const data = await Client.get(url_tokens)
return data
}
export async function createToken(name: string) {
const data = await Client.post(url_tokens, { name })
return data
}
export async function deleteToken(tokenId: string) {
const data = await Client.delete(`${url_tokens}/${tokenId}`)
return data
}
export async function createShareToken(paths: string[], mode: 'ro' | 'rw' = 'ro') {
const data = await Client.post('/api/share-tokens', { paths, mode })
return data as {
id: string
key: string
url: string
mode: 'ro' | 'rw'
paths: string[]
}
}
+22 -19
View File
@@ -1,9 +1,7 @@
import { useMainStore } from "@/stores/main" import { useMainStore } from '@/stores/main'
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia' import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document" import type { FileEntry, UpdateEntry, errorEvent } from './Document'
export const controlUrl = '/api/control'
export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch' export const watchUrl = '/api/watch'
let tree = [] as FileEntry[] let tree = [] as FileEntry[]
@@ -27,18 +25,22 @@ export const loadSession = () => {
console.log(`Loaded session with ${tree.length} items cached`) console.log(`Loaded session with ${tree.length} items cached`)
return true return true
} catch (error) { } catch (error) {
console.log("Loading session failed", error) console.log('Loading session failed', error)
return false return false
} }
} }
const saveSession = () => { const saveSession = () => {
localStorage["cista-files"] = JSON.stringify(tree) localStorage['cista-files'] = JSON.stringify(tree)
} }
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { export const connect = (
path: string,
handlers: Partial<Record<keyof WebSocketEventMap, any>>
) => {
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws'))) const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler) for (const [event, handler] of Object.entries(handlers))
webSocket.addEventListener(event, handler)
return webSocket return webSocket
} }
@@ -91,9 +93,9 @@ export const watchConnect = () => {
wsWatch = connect(watchUrl, { wsWatch = connect(watchUrl, {
message: handleWatchMessage, message: handleWatchMessage,
close: watchReconnect, close: watchReconnect
}) })
wsWatch.addEventListener("message", event => { wsWatch.addEventListener('message', event => {
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) {
@@ -105,7 +107,7 @@ export const watchConnect = () => {
} }
return return
} }
if ("server" in msg) { if ('server' in msg) {
console.log('Connected to backend', msg) console.log('Connected to backend', msg)
store.server = msg.server store.server = msg.server
store.connected = true store.connected = true
@@ -143,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
return 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...'
} }
@@ -153,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
watchTimeout = setTimeout(watchConnect, reconnDelay) watchTimeout = setTimeout(watchConnect, reconnDelay)
} }
const handleWatchMessage = (event: MessageEvent) => { const handleWatchMessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
switch (true) { switch (true) {
@@ -164,7 +165,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)
@@ -193,13 +195,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
if (action === 'k') { if (action === 'k') {
newtree.push(...tree.slice(oidx, oidx + arg)) newtree.push(...tree.slice(oidx, oidx + arg))
oidx += arg oidx += arg
} } else if (action === 'd') oidx += arg
else if (action === 'd') oidx += arg
else if (action === 'i') newtree.push(...arg) else if (action === 'i') newtree.push(...arg)
else console.log("Unknown update action", action, arg) else console.log('Unknown update action', action, arg)
} }
if (oidx != tree.length) if (oidx != tree.length)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`) throw Error(
`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`
)
store.updateRoot(newtree) store.updateRoot(newtree)
tree = newtree tree = newtree
saveSession() saveSession()
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ExplorerView from '@/views/ExplorerView.vue' import ExplorerView from '@/views/ExplorerView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
+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
+206 -42
View File
@@ -1,31 +1,51 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' import type { FUID, FileEntry, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia' import { resumeWatching, watchConnect } from '@/repositories/WS'
import { collator } from '@/utils' import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS' import { type SortOrder, sorted } from '@/utils/docsort'
import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker' import SearchWorker from '@/workers/searchWorker?worker'
import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments } from './documentStore'
// Singleton search worker instance // Singleton search worker instance
let searchWorker: Worker | null = null let searchWorker: Worker | null = null
let searchId = 0 let searchId = 0
let searchStore: ReturnType<typeof useMainStore> | null = null 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 { function getSearchWorker(): Worker {
if (!searchWorker) { if (!searchWorker) {
searchWorker = new SearchWorker() searchWorker = new SearchWorker()
// Set up message handler once // Set up message handler once
searchWorker.onmessage = (e) => { searchWorker.onmessage = e => {
if (!searchStore || e.data.id !== searchId) return // Stale result if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances (constructor is now lightweight) // Convert plain data back to Doc instances
const docs = [] const docs = e.data.docs.map((d: any) => new Doc(d))
for (const d of e.data.docs) {
docs.push(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 searchStore.searchResults = docs
if (e.data.done) { if (e.data.done) {
// Clear the loading timer and hide spinner
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
searchStore.searchLoading = false searchStore.searchLoading = false
} }
} }
@@ -33,9 +53,20 @@ function getSearchWorker(): Worker {
return searchWorker 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', { export const useMainStore = defineStore('main', {
state: () => ({ state: () => ({
document: [] as 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[], searchResults: [] as Doc[],
@@ -48,19 +79,27 @@ export const useMainStore = defineStore('main', {
connected: false, connected: false,
authInProgress: false, authInProgress: false,
cursor: '' as string, cursor: '' as string,
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean }, server: {} as Record<string, any> & { public?: boolean; paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied', dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
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: {
@@ -75,28 +114,89 @@ export const useMainStore = defineStore('main', {
tree.selected = Array.from(tree.selected) tree.selected = Array.from(tree.selected)
return JSON.stringify(tree) return JSON.stringify(tree)
} }
}, }
}, },
actions: { actions: {
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 // Sync documents to search worker
this.syncSearchWorker() 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 */ /** Show a temporary toast message that auto-dismisses */
showToast(message: string, duration = 3000) { showToast(message: string, duration = 3000) {
if (this.toastTimeout) { if (this.toastTimeout) {
@@ -120,13 +220,15 @@ export const useMainStore = defineStore('main', {
syncSearchWorker() { syncSearchWorker() {
const worker = getSearchWorker() const worker = getSearchWorker()
// Send plain data to worker (no class instances) // Send plain data to worker (no class instances)
const docData = this.document.map(doc => ({ const docs = getDocuments()
const docData = docs.map(doc => ({
loc: doc.loc, loc: doc.loc,
name: doc.name, name: doc.name,
key: doc.key, key: doc.key,
size: doc.size, size: doc.size,
allocated: doc.allocated,
mtime: doc.mtime, mtime: doc.mtime,
dir: doc.dir, dir: doc.dir
})) }))
worker.postMessage({ type: 'update', documents: docData }) worker.postMessage({ type: 'update', documents: docData })
}, },
@@ -135,13 +237,44 @@ export const useMainStore = defineStore('main', {
const id = ++searchId const id = ++searchId
searchStore = this // Store reference for worker callback 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) { if (!query) {
// Clear results only when search is closed
this.searchResults = [] this.searchResults = []
this.searchLoading = false this.searchLoading = false
return 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 this.searchLoading = true
}
loadingTimer = null
}, 100)
worker.postMessage({ type: 'search', query, loc, id }) worker.postMessage({ type: 'search', query, loc, id })
}, },
login(username: string, privileged: boolean) { login(username: string, privileged: boolean) {
@@ -154,7 +287,11 @@ export const useMainStore = defineStore('main', {
clearSensitiveData() { clearSensitiveData() {
// Clear all sensitive state on logout or auth failure // Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files') localStorage.removeItem('cista-files')
this.document = [] setDocuments([])
this.ghosts = []
this.hiddenPaths.clear()
this.stopCleanupTimer()
this.docVersion++
this.selected.clear() this.selected.clear()
this.user.username = '' this.user.username = ''
this.user.privileged = false this.user.privileged = false
@@ -164,7 +301,7 @@ export const useMainStore = defineStore('main', {
this.cursor = '' this.cursor = ''
}, },
async logout() { async logout() {
console.log("Logout") console.log('Logout')
try { try {
const res = await fetch('/auth/api/logout', { method: 'POST' }) const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) { if (!res.ok) {
@@ -180,37 +317,67 @@ export const useMainStore = defineStore('main', {
resumeWatching() 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()
}, },
cancelDownloads() { cancelDownloads() {
location.reload() // FIXME location.reload() // FIXME
}, },
cancelUploads() { cancelUploads() {
location.reload() // FIXME location.reload() // FIXME
}, }
}, },
getters: { getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, sortOrder(): SortOrder {
isUserLogged(): boolean { return this.user.isLoggedIn }, return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
recentDocuments(): Doc[] { return sorted(this.document, 'modified') }, },
isUserLogged(): boolean {
return this.user.isLoggedIn
},
/** 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 = {
missing: new Set(), missing: new Set(),
docs: {}, docs: {},
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)
@@ -220,22 +387,19 @@ export const useMainStore = defineStore('main', {
// 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])
} }
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { clearTree } from '@/repositories/WS'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed } from 'vue' import { computed } from 'vue'
import { useMainStore } from './main' import { useMainStore } from './main'
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => { export const useSsoAuthStore = defineStore('ssoAuth', () => {
const isExternalAuth = computed(() => { const isExternalAuth = computed(() => {
+6 -2
View File
@@ -1,10 +1,14 @@
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
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.) */ /** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+45 -16
View File
@@ -26,23 +26,36 @@ export function formatUnixDate(t: number) {
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (adiff <= 5000) return 'now' if (adiff <= 5000) return 'now'
if (adiff <= 60000) { if (adiff <= 60000) {
return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 1000), 'second')
.replace(' ago', '')
.replaceAll(' ', '\u202F')
} }
if (adiff <= 3600000) { if (adiff <= 3600000) {
return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 60000), 'minute')
.replace('utes', '')
.replace('ute', '')
.replaceAll(' ', '\u202F')
} }
if (adiff <= 86400000) { if (adiff <= 86400000) {
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 3600000), 'hour')
.replaceAll(' ', '\u202F')
} }
if (adiff <= 604800000) { if (adiff <= 604800000) {
return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 86400000), 'day')
.replaceAll(' ', '\u202F')
} }
let d = date.toLocaleDateString('en-ie', { let d = date
.toLocaleDateString('en-ie', {
weekday: 'short', weekday: 'short',
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}).replace("Sept", "Sep") })
.replace('Sept', 'Sep')
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space) if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
@@ -63,34 +76,50 @@ interface FileTypes {
const filetypes: FileTypes = { const filetypes: FileTypes = {
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'], video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'], image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
pdf: ['pdf'], pdf: ['pdf']
} }
export function getFileType(name: string): string { 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
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) export const collator = new Intl.Collator('en', {
sensitivity: 'base',
numeric: true,
usage: 'search'
})
// Preformat document names for faster search // Preformat document names for faster search
export function haystackFormat(str: string) { export function haystackFormat(str: string) {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return '^' + based + '$' return '^' + based + '$'
} }
// Preformat search string for faster search // Preformat search string for faster search
export function needleFormat(query: string) { export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = query
return {based, words: based.split(/\s+/)} .normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return { based, words: based.split(/\s+/) }
} }
// Test if haystack includes needle // Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) { export function localeIncludes(
const {based, words} = filter haystack: string,
return haystack.includes(based) || words && words.every(word => haystack.includes(word)) filter: { based: string; words: string[] }
) {
const { based, words } = filter
return (
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
)
} }
+34 -17
View File
@@ -18,11 +18,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue' import FileExplorer from '@/components/FileExplorer.vue'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import { computed, ref, watch, watchEffect } from 'vue'
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -33,17 +34,14 @@ const props = defineProps<{
// Folder path for component keys - only recreate component when folder changes, not search // Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/')) const folderPath = computed(() => props.path.join('/'))
// Trigger search when query changes (from route, e.g., page load or back button)
// Note: Direct typing triggers search immediately via HeaderMain, this is for route-based changes // Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing)
watch( watch(
() => [props.query, props.path.join('/')] as const, () => [props.query, props.path.join('/')] as const,
([query, loc]) => { ([query, loc]) => {
// Only trigger if results don't match current query (avoid duplicate searches) if (store.query === query) return // Already searching this query
if (query && store.searchResults.length === 0) {
store.search(query, loc) store.search(query, loc)
} else if (!query) {
store.search('', loc) // Clear search results
}
}, },
{ immediate: true } { immediate: true }
) )
@@ -53,13 +51,29 @@ const documents = computed(() => {
const query = props.query const query = props.query
// List the current location (no search) // List the current location (no search)
if (!query) return sorted( if (!query) {
store.document.filter(doc => doc.loc === loc), // Access docVersion to make this reactive to document changes
store.prefs.sortListing, void store.docVersion
const hidden = store.hiddenPaths
const docs = getDocuments().filter(
doc =>
doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
) )
// Overlay ghosts for this location (excluding hidden ones)
const ghosts = store.ghosts.filter(
g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name)
)
// Merge: ghosts that don't conflict with real docs
const realNames = new Set(docs.map(d => d.name))
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
return sorted(merged, store.prefs.sortListing)
}
// Search results from worker // Search results from worker (also filter hidden)
const docs = store.searchResults 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 // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
@@ -71,15 +85,18 @@ const documents = computed(() => {
watchEffect(() => { watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
store.query = props.query
}) })
// Only auto-switch gallery mode when entering a new folder or on initial file list load // Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => { watch(
[() => props.path.join('/'), () => store.documentCount],
([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero) // React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return 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>
<style scoped> <style scoped>
+141 -68
View File
@@ -6,6 +6,7 @@ interface DocData {
name: string name: string
key: string key: string
size: number size: number
allocated: number
mtime: number mtime: number
dir: boolean dir: boolean
} }
@@ -36,58 +37,134 @@ interface ResultMessage {
} }
// Worker state // Worker state
let documents: WorkerDoc[] = []
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0 let currentSearchId = 0
// Haystack formatting (same as main thread utils) // Search result cache - cleared when documents change
function haystackFormat(str: string): string { interface CacheEntry {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() query: string // Normalized query string
return '^' + based + '$' 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() +
'$'
)
} }
// Needle formatting function normalizeQuery(str: string): string {
function needleFormat(query: string) { return str
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() .normalize('NFKD')
return { based, words: based.split(/\s+/) } .replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
} }
// Test if haystack includes needle // Test if document matches search query
function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean { function matches(haystack: string, query: string, words: string[]): boolean {
const { based, words } = filter return haystack.includes(query) || words.every(word => haystack.includes(word))
return haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
} }
// Collator for sorting // Collator for sorting
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
// Sort by mtime descending // Yield control to allow new messages to be processed
function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] { const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
return [...docs].sort((a, b) => b.mtime - a.mtime)
// 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
} }
// Yield control to check for new messages // Add result to cache
function yieldControl(): Promise<void> { function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
return new Promise(resolve => setTimeout(resolve, 0)) // 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 // Perform search with incremental results
async function performSearch(query: string, loc: string, searchId: number) { async function performSearch(rawQuery: string, loc: string, searchId: number) {
const needle = needleFormat(query) const query = normalizeQuery(rawQuery)
const limit = 100 const words = query.split(/\s+/)
const batchSize = 500 // Smaller batches for faster incremental feedback
const results: WorkerDoc[] = [] const results: WorkerDoc[] = []
let lastResultCount = 0 let lastResultCount = 0
for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) { // Check cache for exact match
// Check if search was superseded const exactMatch = searchCache.find(e => e.query === query)
if (currentSearchId !== searchId) return 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 // Process batch
const end = Math.min(i + batchSize, recentDocuments.length) const end = Math.min(i + batchSize, recentDocuments.length)
for (let j = i; j < end && results.length < limit; j++) { for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
const doc = recentDocuments[j]! const doc = recentDocuments[j]!
if (localeIncludes(doc.haystack, needle)) { if (matches(doc.haystack, query, words)) {
results.push(doc) results.push(doc)
} }
} }
@@ -95,60 +172,57 @@ async function performSearch(query: string, loc: string, searchId: number) {
// Post incremental results if we found new matches // Post incremental results if we found new matches
if (results.length > lastResultCount && currentSearchId === searchId) { if (results.length > lastResultCount && currentSearchId === searchId) {
lastResultCount = results.length lastResultCount = results.length
const sortedResults = sortResults(results, query, loc) postResults(results, rawQuery, loc, searchId, false)
postMessage({
type: 'results',
docs: sortedResults.map(stripHaystack),
id: searchId,
done: false
} as ResultMessage)
} }
// Yield control between batches to allow new search requests to interrupt // Yield control between batches
if (i + batchSize < recentDocuments.length && results.length < limit) { if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
await yieldControl() await yieldControl()
} }
} }
// Post final results // Cache and post final results
addToCache(query, results, results.length < RESULT_LIMIT)
if (currentSearchId === searchId) { if (currentSearchId === searchId) {
const sortedResults = sortResults(results, query, loc) 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({ postMessage({
type: 'results', type: 'results',
docs: sortedResults.map(stripHaystack), docs: sorted.map(({ haystack, ...rest }) => rest),
id: searchId, id,
done: true done
} as ResultMessage) } as ResultMessage)
}
} }
// Sort results by relevance // Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] { function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/' const locsub = loc + '/'
return [...docs].sort((a, b) => ( return [...docs].sort(
(a, b) =>
// Current folder first // Current folder first
// @ts-ignore Number(b.loc === loc) - Number(a.loc === loc) ||
(b.loc === loc) - (a.loc === loc) ||
// Then subfolders // Then subfolders
// @ts-ignore Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
// Then by location // Then by location
collator.compare(a.loc, b.loc) || collator.compare(a.loc, b.loc) ||
// Files after folders // Folders before files
// @ts-ignore Number(b.dir) - Number(a.dir) ||
(a.dir === false) - (b.dir === false) ||
// Exact name match first // Exact name match first
// @ts-ignore Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
b.name.includes(query) - a.name.includes(query) ||
// Finally by name // Finally by name
collator.compare(a.name, b.name) collator.compare(a.name, b.name)
)) )
}
// Strip haystack before sending back to main thread
function stripHaystack(doc: WorkerDoc): DocData {
const { haystack, ...rest } = doc
return rest
} }
// Handle incoming messages // Handle incoming messages
@@ -156,18 +230,17 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
const msg = e.data const msg = e.data
if (msg.type === 'update') { if (msg.type === 'update') {
// Update document list with haystacks // Update document list with haystacks, sorted by mtime descending
documents = msg.documents.map(doc => ({ recentDocuments = msg.documents
...doc, .map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
haystack: haystackFormat(doc.name) .sort((a, b) => b.mtime - a.mtime)
})) clearCache()
recentDocuments = sortByRecent(documents)
} else if (msg.type === 'search') { } else if (msg.type === 'search') {
currentSearchId = msg.id currentSearchId = msg.id
if (msg.query) { if (msg.query) {
await performSearch(msg.query, msg.loc, msg.id) await performSearch(msg.query, msg.loc, msg.id)
} else { } else {
// Empty query - no results needed (main thread handles folder listing) // Empty query - no results needed
postMessage({ postMessage({
type: 'results', type: 'results',
docs: [], docs: [],
+9 -9
View File
@@ -6,30 +6,30 @@
* - Builds to the Python module's frontend-build directory * - Builds to the Python module's frontend-build directory
* *
* Environment variables (with defaults): * Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying * FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
*/ */
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180" const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || 'http://localhost:8999'
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ['/api'] } = {}) {
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
for (const path of paths) { for (const path of paths) {
proxy[path] = { proxy[path] = {
target: backendUrl, target: backendUrl,
changeOrigin: false, changeOrigin: false,
ws: true, ws: true
} }
} }
return { return {
name: "fastapi-vite", name: 'fastapi-vite',
config: () => ({ config: () => ({
server: { proxy }, server: { proxy },
build: { build: {
outDir: "../cista/frontend-build", outDir: '../cista/frontend-build',
emptyOutDir: true, emptyOutDir: true
}, }
}), })
} }
} }
+13 -15
View File
@@ -1,29 +1,29 @@
import { fileURLToPath, URL } from 'node:url' import { URL, fileURLToPath } from 'node:url'
import fastapiVue from './vite-plugin-fastapi.js' import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
// @ts-ignore // @ts-ignore
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env) // 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"] }), 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
], ],
css: { css: {
preprocessorOptions: { preprocessorOptions: {
less: { less: {
modifyVars: {}, modifyVars: {},
javascriptEnabled: true, javascriptEnabled: true
}, }
}, }
}, },
resolve: { resolve: {
alias: { alias: {
@@ -35,11 +35,9 @@ export default defineConfig({
output: { output: {
manualChunks: { manualChunks: {
// Bundle all SVG icons into a single chunk // Bundle all SVG icons into a single chunk
icons: [ icons: ['/src/assets/svg/index.ts']
'/src/assets/svg/index.ts', }
], }
}, }
}, }
},
},
}) })
+56 -6
View File
@@ -28,7 +28,7 @@ dependencies = [
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"docopt-ng>=0.9.0", "docopt-ng>=0.9.0",
"fastapi-vue>=0.5.1", "fastapi-vue>=0.5.2",
"fastapi[standard]>=0.128.0", "fastapi[standard]>=0.128.0",
"html5tagger>=1.3.0", "html5tagger>=1.3.0",
"httpx>=0.28.0", "httpx>=0.28.0",
@@ -41,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",
"pyvips[binary]>=3.1.1",
"sanic>=25.12.0", "sanic>=25.12.0",
"setproctitle>=1.3.6", "setproctitle>=1.3.6",
"stream-zip>=0.0.83", "stream-zip>=0.0.83",
"tomli_w>=1.2.0", "tomli_w>=1.2.0",
"tracerite>=2.3.1",
"zstandard>=0.24.0", "zstandard>=0.24.0",
] ]
@@ -113,7 +115,57 @@ filterwarnings = [
"ignore::DeprecationWarning", "ignore::DeprecationWarning",
] ]
[tool.ruff]
target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"]
ignore = [
"COM812", # formatter compatibility
"ISC001", # formatter compatibility
"ANN001", # legacy codebase: no full runtime annotation coverage yet
"ANN002", # legacy codebase: no full runtime annotation coverage yet
"ANN003", # legacy codebase: no full runtime annotation coverage yet
"ANN201", # legacy codebase: no full runtime annotation coverage yet
"ANN202", # legacy codebase: no full runtime annotation coverage yet
"ANN204", # legacy codebase: no full runtime annotation coverage yet
"ANN205", # legacy codebase: no full runtime annotation coverage yet
"ARG001", # framework and callback signatures commonly require unused args
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled
"D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized
"D103", # legacy docs not yet standardized
"D104", # legacy docs not yet standardized
"D105", # legacy docs not yet standardized
"D107", # legacy docs not yet standardized
"D200", # legacy docs not yet standardized
"D203", # avoid D203/D211 conflict under ALL selection
"D212", # legacy docs not yet standardized
"D213", # legacy docs not yet standardized
"D400", # legacy docs not yet standardized
"D401", # legacy docs not yet standardized
"D413", # legacy docs not yet standardized
"D415", # legacy docs not yet standardized
"E501", # existing long literals/log strings
"EM101", # exception-message style; low signal for this project
"EM102", # exception-message style; low signal for this project
"INP001", # scripts folder intentionally lacks package markers
"PLC0415", # lazy imports used to avoid startup/circular import issues
"PLR0911", # legacy complexity; keep other correctness rules enabled
"PLR0912", # legacy complexity; keep other correctness rules enabled
"PLR0913", # legacy complexity; keep other correctness rules enabled
"PLR0915", # legacy complexity; keep other correctness rules enabled
"PLR2004", # legacy comparisons use inline constants
"PLW0603", # module-level shared state exists in server runtime code
"SLF001", # cohesive modules occasionally need private-member access
"TRY002", # exception-class strictness too noisy on legacy handlers
"TRY003", # exception-message strictness too noisy on legacy handlers
"TRY004", # type-check strictness too noisy on legacy handlers
"TRY300", # stylistic try/else preference
"TRY301", # stylistic raise-in-try preference
]
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"]
@@ -121,15 +173,13 @@ 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", "httpx>=0.28.1",
] "sanic-testing>=24.6.0",
test = [
"pytest>=8.4.1",
"pytest-cov>=6.0.0",
"pytest-asyncio>=0.25.0",
] ]
[tool.coverage.run] [tool.coverage.run]
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Benchmark OnlyOffice output formats for office document preview.
Compares:
1. BMP → AVIF (via pyvips)
2. PNG → AVIF (via pyvips)
3. PNG only (no AVIF compression)
Usage:
uv run python scripts/benchmark_onlyoffice_formats.py
"""
from __future__ import annotations
import json
import os
import socket
import socketserver
import subprocess
import sys
import threading
import urllib.request
from collections import defaultdict
from dataclasses import dataclass
from functools import partial
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from time import perf_counter
from urllib.parse import quote
import pyvips
os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ONLYOFFICE_URL = os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
CALLBACK_HOST = os.environ.get("ONLYOFFICE_CALLBACK_HOST", "")
AVIF_QUALITY = 60
AVIF_MAXSIZE = 1024
# Directories to scan
SCAN_DIRS = [
Path("/mnt/c/Users/User/Downloads/DocsMisc"),
Path(
"/mnt/c/Users/User/Downloads/Lattialämmityksen säätöarvot As Oy Helsingin Pulteri D ja E.etc"
),
Path("/mnt/c/Users/User/Downloads/As. Oy Aidasmäentie 16-18 teholaskenta.etc"),
]
OFFICE_EXTS = {
".doc",
".dot",
".docx",
".docm",
".dotx",
".dotm",
".rtf",
".odt",
".ott",
".txt",
".md",
".mhtml",
".mht",
".html",
".htm",
".xml",
".wps",
".wri",
".xls",
".xlsx",
".xlsm",
".xlsb",
".xltx",
".xltm",
".ods",
".ots",
".csv",
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
".odp",
".otp",
}
# ---------------------------------------------------------------------------
# OnlyOffice client (inline to avoid import overhead)
# ---------------------------------------------------------------------------
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args) -> None:
pass
def _get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
return s.getsockname()[1]
def _get_callback_host() -> str:
if CALLBACK_HOST:
return CALLBACK_HOST
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
for line in result.stdout.splitlines():
if "inet " in line:
parts = line.strip().split()
return parts[1].split("/")[0]
except Exception:
return "127.0.0.1"
def _serve_file_temporarily(file_path: Path):
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
def onlyoffice_convert(
file_path: Path, output_type: str, timeout: float = 60.0
) -> bytes:
oo_url = ONLYOFFICE_URL.rstrip("/")
convert_url = f"{oo_url}/ConvertService.ashx"
doc_url, httpd = _serve_file_temporarily(file_path)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
"async": False,
"filetype": suffix,
"key": f"bench_{file_path.stat().st_mtime_ns}_{output_type}",
"outputtype": output_type,
"title": file_path.name,
"url": doc_url,
}
req = urllib.request.Request( # noqa: S310
convert_url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
body = resp.read()
text = body.decode("utf-8", errors="replace")
if "<Error>" in text:
code = text.split("<Error>")[1].split("</Error>")[0]
raise RuntimeError(f"OnlyOffice error {code}")
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
file_url = file_url.replace("&amp;", "&")
with urllib.request.urlopen(file_url, timeout=timeout) as img_resp: # noqa: S310
return img_resp.read()
finally:
httpd.shutdown()
# ---------------------------------------------------------------------------
# Benchmark helpers
# ---------------------------------------------------------------------------
@dataclass
class Result:
name: str
ext: str
oo_time: float
avif_time: float = 0.0
raw_size: int = 0
final_size: int = 0
error: str = ""
def avif_from_buffer(
img_bytes: bytes, quality: int = AVIF_QUALITY, maxsize: int = AVIF_MAXSIZE
) -> tuple[float, bytes]:
t0 = perf_counter()
img = pyvips.Image.new_from_buffer(img_bytes, "")
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
buf = img.write_to_buffer(".avif", Q=quality, effort=0, strip=True)
t1 = perf_counter()
return t1 - t0, buf
def benchmark_file(path: Path) -> list[Result]:
results: list[Result] = []
# 1. BMP → AVIF
try:
t0 = perf_counter()
bmp = onlyoffice_convert(path, "bmp")
t1 = perf_counter()
avif_t, avif_buf = avif_from_buffer(bmp)
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=t1 - t0,
avif_time=avif_t,
raw_size=len(bmp),
final_size=len(avif_buf),
)
)
except Exception as e:
results.append(
Result(
name=path.name, ext=path.suffix.lower(), oo_time=0, error=f"bmp: {e}"
)
)
# 2. PNG → AVIF
try:
t0 = perf_counter()
png = onlyoffice_convert(path, "png")
t1 = perf_counter()
avif_t, avif_buf = avif_from_buffer(png)
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=t1 - t0,
avif_time=avif_t,
raw_size=len(png),
final_size=len(avif_buf),
)
)
except Exception as e:
results.append(
Result(
name=path.name, ext=path.suffix.lower(), oo_time=0, error=f"png: {e}"
)
)
# 3. PNG only
try:
t0 = perf_counter()
png = onlyoffice_convert(path, "png")
t1 = perf_counter()
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=t1 - t0,
avif_time=0.0,
raw_size=len(png),
final_size=len(png),
)
)
except Exception as e:
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=0,
error=f"png-only: {e}",
)
)
return results
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
docs: list[Path] = []
for d in SCAN_DIRS:
if not d.exists():
continue
docs.extend(p for p in d.iterdir() if p.suffix.lower() in OFFICE_EXTS)
docs.sort()
total = len(docs)
print(f"Benchmarking {total} documents against OnlyOffice ({ONLYOFFICE_URL})...\n")
all_results: dict[str, list[Result]] = {
"bmp→avif": [],
"png→avif": [],
"png-only": [],
}
for i, doc in enumerate(docs, 1):
print(f"[{i}/{total}] {doc.name} ...", end=" ", flush=True)
res = benchmark_file(doc)
for r, key in zip(res, all_results.keys(), strict=False):
all_results[key].append(r)
if r.error:
print(f"{key} ERR", end=" ")
else:
print(f"{key} OK", end=" ")
print()
# Summary
print("\n" + "=" * 100)
print(
f"{'Format':<12} {'Count':>6} {'OO ms':>10} {'AVIF ms':>10} {'Total ms':>10} {'Raw KB':>10} {'Final KB':>10} {'Ratio':>8}"
)
print("-" * 100)
for key, results in all_results.items():
ok = [r for r in results if not r.error]
errs = [r for r in results if r.error]
if not ok:
continue
avg_oo = sum(r.oo_time for r in ok) / len(ok) * 1000
avg_avif = sum(r.avif_time for r in ok) / len(ok) * 1000
avg_total = avg_oo + avg_avif
avg_raw = sum(r.raw_size for r in ok) / len(ok) / 1024
avg_final = sum(r.final_size for r in ok) / len(ok) / 1024
ratio = avg_raw / avg_final if avg_final else 0
print(
f"{key:<12} {len(ok):>6} {avg_oo:>10.1f} {avg_avif:>10.1f} {avg_total:>10.1f} {avg_raw:>10.1f} {avg_final:>10.1f} {ratio:>8.1f}x"
)
for r in errs[:3]:
print(f" ERROR: {r.name}: {r.error}")
# Per-extension breakdown
print("\n" + "=" * 100)
print("Per-extension summary (png→avif)")
print(
f"{'Ext':<8} {'Count':>6} {'OO ms':>10} {'AVIF ms':>10} {'Total ms':>10} {'Raw KB':>10} {'Final KB':>10}"
)
print("-" * 100)
by_ext: dict[str, list[Result]] = defaultdict(list)
for r in all_results["png→avif"]:
by_ext[r.ext].append(r)
for ext in sorted(by_ext.keys()):
results = [r for r in by_ext[ext] if not r.error]
if not results:
continue
avg_oo = sum(r.oo_time for r in results) / len(results) * 1000
avg_avif = sum(r.avif_time for r in results) / len(results) * 1000
avg_raw = sum(r.raw_size for r in results) / len(results) / 1024
avg_final = sum(r.final_size for r in results) / len(results) / 1024
print(
f"{ext:<8} {len(results):>6} {avg_oo:>10.1f} {avg_avif:>10.1f} {avg_oo + avg_avif:>10.1f} {avg_raw:>10.1f} {avg_final:>10.1f}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
+34 -20
View File
@@ -2,11 +2,13 @@
"""Run Vite development server for frontend and Cista backend with auto-reload. """Run Vite development server for frontend and Cista backend with auto-reload.
Usage: Usage:
uv run scripts/devserver.py [frontend] [--backend backend] uv run scripts/devserver.py [-l listen] [--backend backend] [cista_args...]
Options: Options:
frontend Vite frontend endpoint (default: localhost:5173) -l, --listen Vite frontend endpoint (default: localhost:8989)
--backend Cista backend endpoint (default: from config, or :8000) --backend Cista backend endpoint (default: from config, or :8999)
Any additional arguments are passed to the cista command.
Environment: Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun). JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
@@ -21,38 +23,49 @@ from pathlib import Path
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.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"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore from devutil import ( # type: ignore[import-not-found]
ProcessGroup,
logger,
ready,
setup_vite,
)
from cista import config from cista import config
from cista.serve import parse_listen from cista.serve import parse_listen
DEFAULT_BACKEND_PORT = 8000 DEFAULT_BACKEND_PORT = 8999
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]: def setup_sanic_backend(
listen: str | None, extra_args: list[str]
) -> tuple[str, list[str]]:
"""Parse backend listen address and build cista dev command. """Parse backend listen address and build cista dev command.
Returns (url, cmd). Returns (url, cmd).
""" """
config.load_config() config.load_config()
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}" listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
url, opts = parse_listen(listen) _url, opts = parse_listen(listen)
port = opts.get("port", DEFAULT_BACKEND_PORT) port = opts.get("port", DEFAULT_BACKEND_PORT)
host = opts.get("host", "localhost") or "localhost" host = opts.get("host", "localhost") or "localhost"
cmd = ["cista", "--dev", "-l", listen] # Use the current interpreter/module path so devserver always runs
# workspace source code instead of a potentially stale installed script.
cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen, *extra_args]
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
async def run_devserver(frontend: str | None, backend: str | None) -> None: async def run_devserver(
frontend: str | None, backend: str | None, extra_args: list[str]
) -> None:
reporoot = Path(__file__).parent.parent reporoot = Path(__file__).parent.parent
front = reporoot / "frontend" front = reporoot / "frontend"
if not (front / "package.json").exists(): if not (front / "package.json").exists():
logger.warning("Frontend source not found at %s", front) logger.warning("Frontend source not found at %s", front)
raise SystemExit(1) raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "") _frontend_url, npm_install, vite = setup_vite(frontend or "")
backend_url, sanic_cmd = setup_sanic_backend(backend) backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
# Tell vite where to proxy API requests # Tell vite where to proxy API requests
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
@@ -78,26 +91,27 @@ def main():
epilog=HELP_EPILOG, epilog=HELP_EPILOG,
) )
parser.add_argument( parser.add_argument(
"frontend", "-l",
nargs="?", "--listen",
metavar="host:port", metavar="host:port",
help="Vite frontend endpoint (default: localhost:5173)", help="Vite frontend endpoint (default: localhost:8989)",
) )
parser.add_argument( parser.add_argument(
"--backend", "--backend",
"-l",
metavar="host:port", metavar="host:port",
help="Cista backend endpoint (default: from config, or :8000)", help="Cista backend endpoint (default: from config, or :8999)",
) )
args = parser.parse_args() args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt): with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend)) asyncio.run(run_devserver(args.listen, args.backend, unknown))
HELP_EPILOG = """ HELP_EPILOG = """
scripts/devserver.py # Default ports scripts/devserver.py # Default ports
scripts/devserver.py 3000 # Vite on localhost:3000 scripts/devserver.py -l 3000 # Vite on localhost:3000
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080 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 JS_RUNTIME environment variable can be used to select the JS runtime
""" """
+3 -1
View File
@@ -3,7 +3,9 @@
import sys import sys
from pathlib import Path from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import (
BuildHookInterface, # type: ignore[import-not-found]
)
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build from buildutil import build
+10 -7
View File
@@ -30,8 +30,11 @@ def _check_node_version(node_path: str) -> None:
Raises RuntimeError if version is too old or cannot be determined. Raises RuntimeError if version is too old or cannot be determined.
""" """
try: try:
result = subprocess.run( result = subprocess.run( # noqa: S603
[node_path, "--version"], capture_output=True, text=True, check=True [node_path, "--version"],
capture_output=True,
text=True,
check=True,
) )
version_str = result.stdout.strip() version_str = result.stdout.strip()
# Parse version like "v20.10.0" or "v18.17.1" # Parse version like "v20.10.0" or "v18.17.1"
@@ -134,7 +137,7 @@ def find_dev_tool() -> list[str]:
Raises RuntimeError if no runtime is found. Raises RuntimeError if no runtime is found.
""" """
dev_args = { dev_args = {
"deno": ("run", "dev", "--"), "deno": ("run", "-A", "npm:vite"),
"npm": ("--silent", "run", "dev", "--"), "npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"), "bun": ("run", "dev", "--"),
} }
@@ -176,16 +179,16 @@ def build(folder: str = "frontend") -> None:
install_cmd, build_cmd = find_build_tool() install_cmd, build_cmd = find_build_tool()
except RuntimeError as e: except RuntimeError as e:
logger.warning(e) logger.warning(e)
raise SystemExit(1) raise SystemExit(1) from e
def run(cmd): def run(cmd):
display_cmd = [Path(cmd[0]).name, *cmd[1:]] display_cmd = [Path(cmd[0]).name, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd)) logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder) subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
try: try:
run(install_cmd) run(install_cmd)
logger.info("") logger.info("")
run(build_cmd) run(build_cmd)
except subprocess.CalledProcessError: except subprocess.CalledProcessError as e:
raise SystemExit(1) raise SystemExit(1) from e
+7 -10
View File
@@ -1,14 +1,15 @@
"""Utilities meant for devserver script, used only in source repository with dev deps.""" """Utilities meant for devserver script, used only in source repository with dev deps."""
import asyncio import asyncio
import contextlib
from pathlib import Path from pathlib import Path
import httpx import httpx
from buildutil import find_dev_tool, find_install_tool, logger from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
DEFAULT_VITE_PORT = 5173 DEFAULT_VITE_PORT = 8989
DEFAULT_BACKEND_PORT = 5180 DEFAULT_BACKEND_PORT = 8999
class ProcessGroup: class ProcessGroup:
@@ -58,10 +59,8 @@ class ProcessGroup:
# Terminate remaining processes # Terminate remaining processes
for p in self._procs: for p in self._procs:
if p.returncode is None: if p.returncode is None:
try: with contextlib.suppress(ProcessLookupError):
p.terminate() p.terminate()
except ProcessLookupError:
pass
# Wait for all to finish (with overall timeout) # Wait for all to finish (with overall timeout)
still_running = [p for p in self._procs if p.returncode is None] still_running = [p for p in self._procs if p.returncode is None]
@@ -74,10 +73,8 @@ class ProcessGroup:
except TimeoutError: except TimeoutError:
for p in self._procs: for p in self._procs:
if p.returncode is None: if p.returncode is None:
try: with contextlib.suppress(ProcessLookupError):
p.kill() p.kill()
except ProcessLookupError:
pass
await p.wait() await p.wait()
@@ -95,10 +92,10 @@ async def ready(url: str, path: str = "") -> None:
await client.get(full_url, timeout=1.0) await client.get(full_url, timeout=1.0)
logger.info("✓ Backend ready!") logger.info("✓ Backend ready!")
return return
except httpx.RequestError: except httpx.RequestError as e:
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") logger.warning("Backend didn't start in time")
raise SystemExit(1) raise SystemExit(1) from e
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
+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())
-53
View File
@@ -1,53 +0,0 @@
import tempfile
from pathlib import Path
import pytest
from cista import config
from cista.protocol import Cp, MkDir, Mv, Rename, Rm
@pytest.fixture()
def setup_temp_dir():
with tempfile.TemporaryDirectory() as tmpdirname:
config.config = config.Config(path=Path(tmpdirname), listen=":0")
yield Path(tmpdirname)
def test_mkdir(setup_temp_dir):
cmd = MkDir(path="new_folder")
cmd()
assert (setup_temp_dir / "new_folder").is_dir()
def test_rename(setup_temp_dir):
(setup_temp_dir / "old_name").mkdir()
cmd = Rename(path="old_name", to="new_name")
cmd()
assert not (setup_temp_dir / "old_name").exists()
assert (setup_temp_dir / "new_name").is_dir()
def test_rm(setup_temp_dir):
(setup_temp_dir / "folder_to_remove").mkdir()
cmd = Rm(sel=["folder_to_remove"])
cmd()
assert not (setup_temp_dir / "folder_to_remove").exists()
def test_mv(setup_temp_dir):
(setup_temp_dir / "folder_to_move").mkdir()
(setup_temp_dir / "destination").mkdir()
cmd = Mv(sel=["folder_to_move"], dst="destination")
cmd()
assert not (setup_temp_dir / "folder_to_move").exists()
assert (setup_temp_dir / "destination" / "folder_to_move").is_dir()
def test_cp(setup_temp_dir):
(setup_temp_dir / "folder_to_copy").mkdir()
(setup_temp_dir / "destination").mkdir()
cmd = Cp(sel=["folder_to_copy"], dst="destination")
cmd()
assert (setup_temp_dir / "folder_to_copy").is_dir()
assert (setup_temp_dir / "destination" / "folder_to_copy").is_dir()
+292
View File
@@ -0,0 +1,292 @@
import base64
import hashlib
import hmac
import struct
from pathlib import Path
from time import time
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import auth, config, session, watching
from cista.app import use_session
from cista.fileserver import bp as fileserver_bp
def _basic_auth(username: str, password: str) -> dict[str, str]:
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {creds}"}
def _ntlm_type1() -> dict[str, str]:
msg = b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0x20080205)
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
def _ntlm_type3(
username: str, password: str, domain: str, challenge: bytes
) -> dict[str, str]:
"""Build an NTLMv2 Type 3 message for testing."""
from Crypto.Hash import MD4
# NT hash
nt_hash = MD4.new(password.encode("utf-16le")).digest()
# NTLMv2 hash
ntlmv2_hash = hmac.new(
nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5
).digest()
# Build a minimal blob
timestamp = struct.pack("<Q", 0)
client_nonce = b"\x01" * 8
blob = (
b"\x01\x01\x00\x00\x00\x00\x00\x00"
+ timestamp
+ client_nonce
+ b"\x00\x00\x00\x00"
)
# NT proof
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
nt_response = nt_proof + blob
domain_enc = domain.encode("utf-16le")
username_enc = username.encode("utf-16le")
workstation_enc = b""
lm_response = b"" # Empty for NTLMv2
# Build Type 3 message
msg = bytearray()
msg.extend(b"NTLMSSP\x00")
msg.extend(struct.pack("<I", 3))
# Security buffers offsets will be calculated
payload_start = 64
payloads = []
def add_buf(data: bytes):
offset = payload_start + sum(len(p) for p in payloads)
payloads.append(data)
return struct.pack("<HHI", len(data), len(data), offset)
lm_buf = add_buf(lm_response)
nt_buf = add_buf(nt_response)
domain_buf = add_buf(domain_enc)
user_buf = add_buf(username_enc)
ws_buf = add_buf(workstation_enc)
session_buf = add_buf(b"")
msg.extend(lm_buf)
msg.extend(nt_buf)
msg.extend(domain_buf)
msg.extend(user_buf)
msg.extend(ws_buf)
msg.extend(session_buf)
msg.extend(struct.pack("<I", 0x20080205))
for p in payloads:
msg.extend(p)
return {"Authorization": f"NTLM {base64.b64encode(bytes(msg)).decode()}"}
def _session_cookie_header(username: str) -> dict[str, str]:
token = "test-" + username
session._sessions[token] = {
"exp": int(time()) + session.max_age,
"username": username,
}
return {"Cookie": f"cista={token}"}
@pytest.fixture
def setup_storage(tmp_path: Path):
user = config.User()
auth.set_password(user, "secret")
token = config.Token(key="test_token_123", username="alice")
share_ro = config.Token(
key="share_ro_123",
username="alice",
kind="share",
mode="ro",
share_paths=["hello.txt", "docs"],
)
share_rw = config.Token(
key="share_rw_123",
username="alice",
kind="share",
mode="rw",
share_paths=["docs"],
)
config.config = config.Config(
path=tmp_path,
listen=":0",
public=False,
users={"alice": user},
tokens={
"test_token_123": token,
"share_ro_123": share_ro,
"share_rw_123": share_rw,
},
)
watching.state.root = []
watching.rootpath = tmp_path
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
(tmp_path / "secret.txt").write_text("secret", encoding="utf-8")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
(tmp_path / "docs" / "b.txt").write_text("B", encoding="utf-8")
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
@app.on_request
async def load_auth_context(request):
await use_session(request)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_basic_auth_allows_private_file_access(client):
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("alice", "secret")
)
assert res.status_code == 200
assert res.body == b"hello"
assert "set-cookie" not in res.headers
@pytest.mark.asyncio
async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client):
_, res = await client.get(
"/files/hello.txt",
headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")},
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_options_unauthenticated_allowed(client):
_, res = await client.options("/files/")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_unauthenticated_sends_basic_auth_challenge(client):
_, res = await client.request("PROPFIND", "/files/")
assert res.status_code == 401
assert (
res.headers.get("www-authenticate", "")
.lower()
.startswith('basic realm="cista"')
)
@pytest.mark.asyncio
async def test_basic_auth_with_token(client):
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "test_token_123")
)
assert res.status_code == 200
assert res.body == b"hello"
@pytest.mark.asyncio
async def test_browser_unauthenticated_sends_cookie_challenge(client):
_, res = await client.get(
"/files/", headers={"Accept": "text/html,application/xhtml+xml"}
)
assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
@pytest.mark.asyncio
async def test_ntlm_auth_with_token(client):
# Step 1: request without auth should NOT advertise NTLM
# (we prefer clients use BASIC; NTLM still works if client initiates it)
_, res1 = await client.get("/files/hello.txt")
assert res1.status_code == 401
assert "ntlm" not in res1.headers.get("www-authenticate", "").lower()
# Step 2: client proactively sends Type 1, gets Type 2 challenge
_, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1())
assert res2.status_code == 401
auth_hdr = res2.headers.get("www-authenticate", "")
assert auth_hdr.lower().startswith("ntlm ")
type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1])
challenge = type2_data[24:32]
# Step 3: send Type 3 with token as password
_, res3 = await client.get(
"/files/hello.txt",
headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge),
)
assert res3.status_code == 200
assert res3.body == b"hello"
@pytest.mark.asyncio
async def test_share_token_limits_visible_paths(client):
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 200
assert res.body == b"A"
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 200
assert res.body == b"hello"
_, res = await client.get(
"/files/secret.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_share_token_read_only_blocks_writes(client):
_, res = await client.delete(
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_share_token_rw_allows_writes_in_scope_only(client):
_, res = await client.delete(
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 204
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 404
_, res = await client.delete(
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 404
+176
View File
@@ -0,0 +1,176 @@
"""Path traversal and percent-encoding security tests for the fileserver."""
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
# ---------------------------------------------------------------------------
# %2F — encoded slash should be decoded as a path separator
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_percent2f_decoded_as_path_separator(client, setup_storage: Path):
"""%2F in the URL path is decoded to '/' and treated as a path separator."""
(setup_storage / "sub").mkdir()
(setup_storage / "sub" / "file.txt").write_text("hello", encoding="utf-8")
_, res = await client.get("/files/sub%2Ffile.txt")
assert res.status_code == 200
assert res.text == "hello"
@pytest.mark.asyncio
async def test_mkcol_percent2f_creates_nested_directory(client, setup_storage: Path):
"""%2F in MKCOL path is decoded as a separator, creating nested dirs."""
_, res = await client.request("MKCOL", "/files/parent%2Fchild")
assert res.status_code == 201
assert (setup_storage / "parent" / "child").is_dir()
# ---------------------------------------------------------------------------
# %20 — encoded space in filename
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_percent20_in_filename(client, setup_storage: Path):
(setup_storage / "my file.txt").write_text("spaced", encoding="utf-8")
_, res = await client.get("/files/my%20file.txt")
assert res.status_code == 200
assert res.text == "spaced"
@pytest.mark.asyncio
async def test_mkcol_percent20_in_folder_name(client, setup_storage: Path):
_, res = await client.request("MKCOL", "/files/my%20folder")
assert res.status_code == 201
assert (setup_storage / "my folder").is_dir()
# ---------------------------------------------------------------------------
# Path traversal — .. and encoded variants
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_dotdot_rejected(client):
""".. is path-normalised by the router before reaching the handler."""
_, res = await client.get("/files/..")
assert res.status_code in (400, 404)
@pytest.mark.asyncio
async def test_get_dotdot_segment_rejected(client):
"""Traversal via sub/../.. is path-normalised by the router."""
_, res = await client.get("/files/sub/../..")
assert res.status_code in (400, 404)
@pytest.mark.asyncio
async def test_get_encoded_dotdot_rejected(client):
"""%2E%2E (encoded ..) must be rejected."""
_, res = await client.get("/files/%2E%2E")
assert res.status_code == 400
@pytest.mark.asyncio
async def test_get_encoded_dotdot_segment_rejected(client):
"""%2E%2E used as a segment in a longer path must be rejected."""
_, res = await client.get("/files/sub%2F%2E%2E%2F..%2Fetc%2Fpasswd")
assert res.status_code == 400
@pytest.mark.asyncio
async def test_mkcol_dotdot_rejected(client):
_, res = await client.request("MKCOL", "/files/..")
assert res.status_code in (400, 404)
@pytest.mark.asyncio
async def test_delete_dotdot_rejected(client):
_, res = await client.delete("/files/..")
assert res.status_code in (400, 404)
# ---------------------------------------------------------------------------
# Dot-prefixed filenames (.hidden, ...)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_hidden_file_rejected(client):
"""Names starting with '.' are not allowed."""
_, res = await client.get("/files/.hidden")
assert res.status_code == 400
@pytest.mark.asyncio
async def test_mkcol_hidden_folder_rejected(client):
_, res = await client.request("MKCOL", "/files/.secret")
assert res.status_code == 400
# ---------------------------------------------------------------------------
# Windows-style drive paths (c:/) — safe on Linux, stays inside storage root
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mkcol_windows_drive_path_stays_within_root(client, setup_storage: Path):
"""A Windows-style drive path like 'c:/foo' is treated as a relative path
on Linux and resolves safely inside the storage root."""
_, res = await client.request("MKCOL", "/files/c:/secret")
# Either created inside the storage root (201) or sanitised away (400/404).
# The important assertion: nothing was created outside the storage root.
assert not (Path("/c:") / "secret").exists()
assert not (Path("c:/secret")).exists()
if res.status_code == 201:
# Created safely inside tmp storage
assert (setup_storage / "c:" / "secret").is_dir()
@pytest.mark.asyncio
async def test_mkcol_backslash_in_path_sanitised(client, setup_storage: Path):
"""Backslashes are replaced with dashes, not treated as path separators."""
_, res = await client.request("MKCOL", "/files/foo\\..\\bar")
assert res.status_code in (201, 400)
# Must not escape storage root
assert not (setup_storage.parent / "bar").exists()
+242
View File
@@ -0,0 +1,242 @@
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
from cista.protocol import FileEntry
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_mkcol_creates_directory(client, setup_storage: Path):
_, res = await client.request("MKCOL", "/files/new-folder")
assert res.status_code == 201
assert (setup_storage / "new-folder").is_dir()
@pytest.mark.asyncio
async def test_delete_removes_file(client, setup_storage: Path):
file_path = setup_storage / "delete-me.txt"
file_path.write_text("hello", encoding="utf-8")
_, res = await client.delete("/files/delete-me.txt")
assert res.status_code == 204
assert not file_path.exists()
@pytest.mark.asyncio
async def test_post_mv_moves_keys_to_target(client, setup_storage: Path):
(setup_storage / "target").mkdir()
(setup_storage / "alpha.txt").write_text("alpha", encoding="utf-8")
(setup_storage / "beta.txt").write_text("beta", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "alpha.txt", "k-alpha", 0, 5, 0, 1),
FileEntry(1, "beta.txt", "k-beta", 0, 4, 0, 1),
]
_, res = await client.post("/files/target?mv=k-alpha+k-beta")
assert res.status_code == 200
assert res.json["status"] == "ack"
assert not (setup_storage / "alpha.txt").exists()
assert not (setup_storage / "beta.txt").exists()
assert (setup_storage / "target" / "alpha.txt").is_file()
assert (setup_storage / "target" / "beta.txt").is_file()
@pytest.mark.asyncio
async def test_post_cp_copies_keys_to_target(client, setup_storage: Path):
(setup_storage / "target").mkdir()
(setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1),
]
_, res = await client.post("/files/target?cp=k-copy")
assert res.status_code == 200
assert res.json["counts"] == {"cp": 1, "mv": 0}
assert (setup_storage / "copy-me.txt").is_file()
assert (setup_storage / "target" / "copy-me.txt").is_file()
@pytest.mark.asyncio
async def test_post_cp_repeated_params_and_plus_form_are_equivalent(
client,
setup_storage: Path,
):
(setup_storage / "target").mkdir()
(setup_storage / "one.txt").write_text("one", encoding="utf-8")
(setup_storage / "two.txt").write_text("two", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "one.txt", "k-one", 0, 3, 0, 1),
FileEntry(1, "two.txt", "k-two", 0, 3, 0, 1),
]
_, res1 = await client.post("/files/target?cp=k-one&cp=k-two")
assert res1.status_code == 200
assert (setup_storage / "target" / "one.txt").is_file()
assert (setup_storage / "target" / "two.txt").is_file()
(setup_storage / "target" / "one.txt").unlink()
(setup_storage / "target" / "two.txt").unlink()
_, res2 = await client.post("/files/target?cp=k-one+k-two")
assert res2.status_code == 200
assert (setup_storage / "target" / "one.txt").is_file()
assert (setup_storage / "target" / "two.txt").is_file()
@pytest.mark.asyncio
async def test_post_mv_with_to_renames_single_key(
client,
setup_storage: Path,
):
(setup_storage / "dst").mkdir()
(setup_storage / "old-name.txt").write_text("x", encoding="utf-8")
watching.state.root = [
FileEntry(1, "dst", "k-dst", 0, 0, 0, 0),
FileEntry(1, "old-name.txt", "k-old", 0, 1, 0, 1),
]
_, res = await client.post("/files/dst/new-name.txt?mv=k-old")
assert res.status_code == 200
assert not (setup_storage / "old-name.txt").exists()
assert (setup_storage / "dst" / "new-name.txt").is_file()
@pytest.mark.asyncio
async def test_post_cp_single_key_to_file_path(client, setup_storage: Path):
(setup_storage / "dst").mkdir()
(setup_storage / "src.txt").write_text("copy", encoding="utf-8")
watching.state.root = [
FileEntry(1, "dst", "k-dst", 0, 0, 0, 0),
FileEntry(1, "src.txt", "k-src", 0, 4, 0, 1),
]
_, res = await client.post("/files/dst/copied.txt?cp=k-src")
assert res.status_code == 200
assert (setup_storage / "src.txt").is_file()
assert (setup_storage / "dst" / "copied.txt").is_file()
@pytest.mark.asyncio
async def test_post_supports_combined_cp_then_mv(client, setup_storage: Path):
(setup_storage / "target").mkdir()
(setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8")
(setup_storage / "move-me.txt").write_text("move", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1),
FileEntry(1, "move-me.txt", "k-move", 0, 4, 0, 1),
]
_, res = await client.post("/files/target?cp=k-copy&mv=k-move")
assert res.status_code == 200
assert res.json["counts"] == {"cp": 1, "mv": 1}
assert (setup_storage / "copy-me.txt").is_file()
assert not (setup_storage / "move-me.txt").exists()
assert (setup_storage / "target" / "copy-me.txt").is_file()
assert (setup_storage / "target" / "move-me.txt").is_file()
@pytest.mark.asyncio
async def test_post_rejects_unknown_query_args(client):
_, res = await client.post("/files/?cp=k1&wat=1")
assert res.status_code == 400
assert "unknown query parameter" in res.json["message"].lower()
@pytest.mark.asyncio
async def test_post_requires_query_args(client):
_, res = await client.post("/files/")
assert res.status_code == 400
assert "no query arguments" in res.json["message"].lower()
@pytest.mark.asyncio
async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage: Path):
(setup_storage / "a.txt").write_text("a", encoding="utf-8")
(setup_storage / "b.txt").write_text("b", encoding="utf-8")
(setup_storage / "target.txt").write_text("x", encoding="utf-8")
watching.state.root = [
FileEntry(1, "a.txt", "k-a", 0, 1, 0, 1),
FileEntry(1, "b.txt", "k-b", 0, 1, 0, 1),
FileEntry(1, "target.txt", "k-target", 0, 1, 0, 1),
]
_, cp_res = await client.post("/files/target.txt?cp=k-a+k-b")
_, mv_res = await client.post("/files/target.txt?mv=k-a+k-b")
assert cp_res.status_code == 400
assert "existing directory" in cp_res.json["message"].lower()
assert mv_res.status_code == 400
assert "existing directory" in mv_res.json["message"].lower()
@pytest.mark.asyncio
async def test_post_rejects_directory_to_existing_file_target(
client, setup_storage: Path
):
(setup_storage / "folder").mkdir()
(setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8")
(setup_storage / "existing.txt").write_text("e", encoding="utf-8")
watching.state.root = [
FileEntry(1, "folder", "k-folder", 0, 0, 0, 0),
FileEntry(2, "nested.txt", "k-nested", 0, 1, 0, 1),
FileEntry(1, "existing.txt", "k-existing", 0, 1, 0, 1),
]
_, cp_res = await client.post("/files/existing.txt?cp=k-folder")
_, mv_res = await client.post("/files/existing.txt?mv=k-folder")
assert cp_res.status_code == 400
assert "directory to an existing file" in cp_res.json["message"].lower()
assert mv_res.status_code == 400
assert "directory to an existing file" in mv_res.json["message"].lower()
+106
View File
@@ -0,0 +1,106 @@
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_get_file_full_content(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt")
assert res.status_code == 200
assert res.body == b"hello world"
assert res.headers.get("accept-ranges") == "bytes"
assert res.headers.get("content-length") == "11"
@pytest.mark.asyncio
async def test_head_file_returns_headers_without_body(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.head("/files/hello.txt")
assert res.status_code == 200
assert not res.body
assert res.headers.get("content-length") == "11"
@pytest.mark.asyncio
async def test_get_file_range_start_end(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=1-4"})
assert res.status_code == 206
assert res.body == b"ello"
assert res.headers.get("content-range") == "bytes 1-4/11"
assert res.headers.get("content-length") == "4"
@pytest.mark.asyncio
async def test_get_file_suffix_range(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=-5"})
assert res.status_code == 206
assert res.body == b"world"
assert res.headers.get("content-range") == "bytes 6-10/11"
@pytest.mark.asyncio
async def test_head_file_with_range(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.head("/files/hello.txt", headers={"Range": "bytes=0-4"})
assert res.status_code == 206
assert not res.body
assert res.headers.get("content-range") == "bytes 0-4/11"
assert res.headers.get("content-length") == "5"
@pytest.mark.asyncio
async def test_get_file_unsatisfiable_range_returns_416(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=99-100"})
assert res.status_code == 416
assert res.headers.get("content-range") == "bytes */11"
+274
View File
@@ -0,0 +1,274 @@
"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK."""
import xml.etree.ElementTree as ET
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
_DAV_NS = "DAV:"
_METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND")
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-dav-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, *_METHODS)
app.blueprint(fileserver_bp)
yield app.asgi_client
def _dav(tag: str) -> str:
return f"{{{_DAV_NS}}}{tag}"
def _parse_multistatus(body: bytes) -> list[ET.Element]:
root = ET.fromstring(body)
assert root.tag == _dav("multistatus")
return root.findall(_dav("response"))
# ---------------------------------------------------------------------------
# OPTIONS
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_options_advertises_dav_class(client):
_, res = await client.options("/files/")
assert res.status_code == 200
assert "1" in res.headers.get("dav", "")
assert "PROPFIND" in res.headers.get("allow", "")
assert "COPY" in res.headers.get("allow", "")
assert "MOVE" in res.headers.get("allow", "")
@pytest.mark.asyncio
async def test_options_without_trailing_slash(client):
"""WebDAV clients (e.g. Windows) send OPTIONS /files without trailing slash."""
_, res = await client.options("/files")
assert res.status_code == 200
assert "1" in res.headers.get("dav", "")
# ---------------------------------------------------------------------------
# PROPFIND
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_propfind_root_depth0(client, setup_storage: Path):
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "0"})
assert res.status_code == 207
responses = _parse_multistatus(res.body)
assert len(responses) == 1
href = responses[0].findtext(_dav("href"))
assert href == "/files/"
rt = responses[0].find(f".//{_dav('resourcetype')}/{_dav('collection')}")
assert rt is not None, "Root should be a collection"
@pytest.mark.asyncio
async def test_propfind_root_depth1_lists_children(client, setup_storage: Path):
(setup_storage / "alpha.txt").write_text("a", encoding="utf-8")
(setup_storage / "beta").mkdir()
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "1"})
assert res.status_code == 207
responses = _parse_multistatus(res.body)
hrefs = [r.findtext(_dav("href")) for r in responses]
assert "/files/" in hrefs
assert "/files/alpha.txt" in hrefs
assert "/files/beta/" in hrefs
@pytest.mark.asyncio
async def test_propfind_file_has_content_length(client, setup_storage: Path):
(setup_storage / "data.txt").write_text("hello", encoding="utf-8")
_, res = await client.request("PROPFIND", "/files/data.txt", headers={"Depth": "0"})
assert res.status_code == 207
responses = _parse_multistatus(res.body)
cl = responses[0].findtext(f".//{_dav('getcontentlength')}")
assert cl == "5"
@pytest.mark.asyncio
async def test_propfind_depth_infinity_rejected(client, setup_storage: Path):
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "infinity"})
assert res.status_code == 403
@pytest.mark.asyncio
async def test_propfind_missing_resource_returns_404(client):
_, res = await client.request("PROPFIND", "/files/no-such-file.txt")
assert res.status_code == 404
# ---------------------------------------------------------------------------
# COPY
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_copy_file_to_new_path(client, setup_storage: Path):
(setup_storage / "src.txt").write_text("copy me", encoding="utf-8")
_, res = await client.request(
"COPY",
"/files/src.txt",
headers={"Destination": "http://localhost/files/dst.txt"},
)
assert res.status_code == 201
assert (setup_storage / "src.txt").is_file()
assert (setup_storage / "dst.txt").read_text() == "copy me"
@pytest.mark.asyncio
async def test_copy_overwrites_existing_by_default(client, setup_storage: Path):
(setup_storage / "src.txt").write_text("new", encoding="utf-8")
(setup_storage / "dst.txt").write_text("old", encoding="utf-8")
_, res = await client.request(
"COPY",
"/files/src.txt",
headers={"Destination": "http://localhost/files/dst.txt"},
)
assert res.status_code == 204
assert (setup_storage / "dst.txt").read_text() == "new"
@pytest.mark.asyncio
async def test_copy_overwrite_false_returns_412(client, setup_storage: Path):
(setup_storage / "src.txt").write_text("x", encoding="utf-8")
(setup_storage / "dst.txt").write_text("y", encoding="utf-8")
_, res = await client.request(
"COPY",
"/files/src.txt",
headers={
"Destination": "http://localhost/files/dst.txt",
"Overwrite": "F",
},
)
assert res.status_code == 412
assert (setup_storage / "dst.txt").read_text() == "y"
@pytest.mark.asyncio
async def test_copy_directory_recursively(client, setup_storage: Path):
(setup_storage / "src").mkdir()
(setup_storage / "src" / "child.txt").write_text("child", encoding="utf-8")
_, res = await client.request(
"COPY",
"/files/src",
headers={"Destination": "http://localhost/files/dst"},
)
assert res.status_code == 201
assert (setup_storage / "dst" / "child.txt").read_text() == "child"
assert (setup_storage / "src" / "child.txt").is_file()
@pytest.mark.asyncio
async def test_copy_missing_parent_returns_409(client, setup_storage: Path):
(setup_storage / "src.txt").write_text("x", encoding="utf-8")
_, res = await client.request(
"COPY",
"/files/src.txt",
headers={"Destination": "http://localhost/files/nodir/dst.txt"},
)
assert res.status_code == 409
# ---------------------------------------------------------------------------
# MOVE
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_move_renames_file(client, setup_storage: Path):
(setup_storage / "old.txt").write_text("data", encoding="utf-8")
_, res = await client.request(
"MOVE",
"/files/old.txt",
headers={"Destination": "http://localhost/files/new.txt"},
)
assert res.status_code == 201
assert not (setup_storage / "old.txt").exists()
assert (setup_storage / "new.txt").read_text() == "data"
@pytest.mark.asyncio
async def test_move_overwrites_existing(client, setup_storage: Path):
(setup_storage / "src.txt").write_text("src", encoding="utf-8")
(setup_storage / "dst.txt").write_text("dst", encoding="utf-8")
_, res = await client.request(
"MOVE",
"/files/src.txt",
headers={"Destination": "http://localhost/files/dst.txt"},
)
assert res.status_code == 204
assert not (setup_storage / "src.txt").exists()
assert (setup_storage / "dst.txt").read_text() == "src"
@pytest.mark.asyncio
async def test_move_overwrite_false_returns_412(client, setup_storage: Path):
(setup_storage / "src.txt").write_text("src", encoding="utf-8")
(setup_storage / "dst.txt").write_text("dst", encoding="utf-8")
_, res = await client.request(
"MOVE",
"/files/src.txt",
headers={
"Destination": "http://localhost/files/dst.txt",
"Overwrite": "F",
},
)
assert res.status_code == 412
assert (setup_storage / "src.txt").is_file()
@pytest.mark.asyncio
async def test_move_same_source_and_dest_is_noop(client, setup_storage: Path):
(setup_storage / "file.txt").write_text("x", encoding="utf-8")
_, res = await client.request(
"MOVE",
"/files/file.txt",
headers={"Destination": "http://localhost/files/file.txt"},
)
assert res.status_code == 204
assert (setup_storage / "file.txt").is_file()
+6 -6
View File
@@ -12,19 +12,19 @@ def mock_open(key):
def test_contains(): def test_contains():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
assert "key1" not in cache assert "key1" not in cache
cache["key1"] cache["key1"]
assert "key1" in cache assert "key1" in cache
def test_getitem(): def test_getitem():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
assert cache["key1"].content == "content-key1" assert cache["key1"].content == "content-key1"
def test_capacity(): def test_capacity():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"] item1 = cache["key1"]
cache["key2"] cache["key2"]
cache["key3"] cache["key3"]
@@ -33,7 +33,7 @@ def test_capacity():
def test_expiry(): def test_expiry():
cache = LRUCache(open=mock_open, capacity=2, maxage=0.1) cache = LRUCache(opener=mock_open, capacity=2, maxage=0.1)
item = cache["key1"] item = cache["key1"]
sleep(0.2) # Wait for expiration sleep(0.2) # Wait for expiration
cache.expire_items() cache.expire_items()
@@ -42,7 +42,7 @@ def test_expiry():
def test_close(): def test_close():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item = cache["key1"] item = cache["key1"]
cache.close() cache.close()
assert "key1" not in cache assert "key1" not in cache
@@ -50,7 +50,7 @@ def test_close():
def test_lru_mechanism(): def test_lru_mechanism():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"] item1 = cache["key1"]
item2 = cache["key2"] item2 = cache["key2"]
cache["key1"] # Make key1 recently used cache["key1"] # Make key1 recently used
+220
View File
@@ -0,0 +1,220 @@
import os
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import auth, config, watching
from cista.api import bp as api_bp
from cista.auth import bp as auth_bp
def _persist_config():
from pathlib import PurePath
import msgspec
def enc_hook(obj):
if isinstance(obj, PurePath):
return obj.as_posix()
raise TypeError
raw = msgspec.to_builtins(config.config, enc_hook=enc_hook)
config.conffile.write_bytes(msgspec.toml.encode(raw))
@pytest.fixture
def setup_storage(tmp_path: Path):
os.environ["CISTA_HOME"] = str(tmp_path)
config.init_confdir()
user = config.User()
auth.set_password(user, "secret")
admin = config.User(privileged=True)
auth.set_password(admin, "secret")
config.config = config.Config(
path=tmp_path,
listen=":0",
public=False,
users={"alice": user, "admin": admin},
)
_persist_config()
watching.state.root = []
watching.rootpath = tmp_path
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"token-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(auth_bp)
app.blueprint(api_bp)
yield app.asgi_client
def _basic_auth(username: str, password: str) -> str:
return f"Basic {__import__('base64').b64encode(f'{username}:{password}'.encode()).decode()}"
@pytest.mark.asyncio
async def test_token_crud(client):
# Admin creates a token without specifying username (auto-assigned)
_, res = await client.post(
"/auth/tokens",
json={"name": "test"},
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
data = res.json
assert "id" in data
assert "key" in data
assert data["username"] == "admin"
assert data["name"] == "test"
token_id = data["id"]
token_key = data["key"]
# List tokens - admin sees only their own
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
tokens = res.json["tokens"]
assert len(tokens) == 1
assert tokens[0]["id"] == token_id
assert tokens[0]["username"] == "admin"
# Use token via Basic auth (token:<secret>)
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("token", token_key)},
)
assert res.status_code == 200
# Delete token
_, res = await client.delete(
f"/auth/tokens/{token_id}",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
# List should be empty
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
assert len(res.json["tokens"]) == 0
@pytest.mark.asyncio
async def test_token_user_scoped(client):
# Alice creates a token for herself (no username specified)
_, res = await client.post(
"/auth/tokens",
json={"name": "alice-token"},
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
alice_token_id = res.json["id"]
alice_token_key = res.json["key"]
# Admin creates a token for themselves
_, res = await client.post(
"/auth/tokens",
json={"name": "admin-token"},
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
admin_token_id = res.json["id"]
# Alice lists tokens - sees only her own
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
tokens = res.json["tokens"]
assert len(tokens) == 1
assert tokens[0]["id"] == alice_token_id
assert tokens[0]["username"] == "alice"
# Admin lists tokens - sees only their own
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
tokens = res.json["tokens"]
assert len(tokens) == 1
assert tokens[0]["id"] == admin_token_id
assert tokens[0]["username"] == "admin"
# Alice cannot create a token for admin
_, res = await client.post(
"/auth/tokens",
json={"username": "admin", "name": "impersonation"},
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 403
# Alice cannot delete admin's token
_, res = await client.delete(
f"/auth/tokens/{admin_token_id}",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 403
# Alice can delete her own token
_, res = await client.delete(
f"/auth/tokens/{alice_token_id}",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
# Alice's token auth still works until deletion is processed
# Verify token auth worked during the test
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("token", alice_token_key)},
)
# Token was deleted above, so this should now be unauthenticated
# Actually the token key lookup will fail, and since there's no session fallback...
# With auth header present but invalid, it should return 401
assert res.status_code == 401
@pytest.mark.asyncio
async def test_create_share_token(client):
_, res = await client.post(
"/api/share-tokens",
json={"paths": ["hello.txt", "docs"], "mode": "ro", "name": "selection"},
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
data = res.json
assert data["kind"] == "share"
assert data["mode"] == "ro"
assert data["paths"] == ["hello.txt", "docs"]
assert "token:" in data["url"]
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
assert len(share_tokens) == 1
assert share_tokens[0]["mode"] == "ro"
+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