Compare commits

..
131 Commits
Author SHA1 Message Date
LeoVasanko 8270dd0cc2 Refactor to make full file list completely non-reactive because pinia persistence was causing long delays especially while searching when there were a lot of files. Implement better ghosts that do not alter the file list. 2026-02-04 02:03:22 +00:00
LeoVasanko 1af6cd82fe Prebuild lookup structures to reduce UI lag on very large file lists. 2026-02-04 00:18:28 +00:00
LeoVasanko 0bc2a12cfa Make devserver script pass extra args to cista CLI. 2026-02-04 00:01:02 +00:00
LeoVasanko 9e07a3f40d Require new fastapi-vue for unix socket compatibility. 2026-02-01 07:10:21 +00:00
LeoVasanko 2867b1075d Disk space pie chart. 2026-02-01 06:59:15 +00:00
LeoVasanko 4fc8e29cc1 Implement a larger set of file menu options. 2026-02-01 06:33:34 +00:00
LeoVasanko 55f9117b3e Remove refresh wwwroot support and static files serving, only using vite to serve frontend in dev mode (avoid accidentally using stale builds). 2026-02-01 06:14:35 +00:00
LeoVasanko 2b6746c55e Change default port to 8989 like Droppy. Using 8999 for backend in dev mode. 2026-02-01 06:09:56 +00:00
LeoVasanko 1ed3779933 Use fastapi-vue parse_endpoint instead of our own logic. 2026-02-01 06:00:30 +00:00
LeoVasanko a95cf7be94 Better UI feedback on search order changes. 2026-02-01 05:48:15 +00:00
LeoVasanko d18501f633 Fix a search caching issue. 2026-02-01 05:38:48 +00:00
LeoVasanko ed697db871 New header/footer layout and styles. Use cursorTooltip in more places, make it prettier. Fix tooltip still sometimes activating with touch (now only with mouse). 2026-02-01 05:19:42 +00:00
LeoVasanko 856e8c4cc8 Remove download to folder, just download zip or single file. The folder download is still accessible by Alt+Click on the download button (in the few browsers that support it). 2026-02-01 03:47:14 +00:00
LeoVasanko f8a6eacb06 Automatically navigate into newly uploaded folders. 2026-02-01 03:27:13 +00:00
LeoVasanko e9a82e84ad Implement ghost items reflecting pending changes from frontend (e.g. files being uploaded) until the server acknowledges them. This gives immediate UI feedback and is clearer in cases. 2026-02-01 03:23:43 +00:00
LeoVasanko ccd05b53f4 Remove dead code. 2026-02-01 02:49:50 +00:00
LeoVasanko 032ad13b97 Unified watcher that *may* receive events from inotify and other sources. Added change notify messages from control and upload WebSockets. Cleanup debug printouts. 2026-02-01 02:44:52 +00:00
LeoVasanko 4eae75c84b New inotify watcher with efficient algorithms. 2026-01-31 23:46:43 +00:00
LeoVasanko 252b31a293 Performance profile file change watching. 2026-01-31 22:53:17 +00:00
LeoVasanko feff202161 Debouncing to avoid UI flicker while typing in search. 2026-01-31 22:42:16 +00:00
LeoVasanko d15bfc86c4 Search optimizations and fixes. Prevent re-renders on hash change (redux). Clear previous search results at the start of a new search. 2026-01-31 22:37:47 +00:00
LeoVasanko f94ccc01c6 Optimizing the search faster; don't trigger full re render by URL changes. 2026-01-31 22:17:05 +00:00
LeoVasanko bc355d991d Simplified startup box unix socket formatting. 2026-01-31 22:09:57 +00:00
LeoVasanko e7ebad3c36 Upgrade to docopt-ng because docopt is no longer maintained. 2026-01-31 22:06:03 +00:00
LeoVasanko 036c4342de Better error logging on preview generation errors. 2026-01-31 22:00:26 +00:00
LeoVasanko 160f929e0c Basic details in startup box. 2026-01-31 21:53:39 +00:00
LeoVasanko 1970d40d8a Implement background worker to make search response faster and avoid hanging the UI when there are a lot of files. Implement better toast messages for transitional events that automatically disappear. 2026-01-31 21:02:16 +00:00
LeoVasanko 1a164e0a08 Smarter ZIP download naming. 2026-01-31 20:59:43 +00:00
LeoVasanko 46d222006a More robust zip download processing; don't let aborted requests hang threads, use a larger threadpool separate of preview generation. 2026-01-31 19:43:23 +00:00
LeoVasanko 98949e6b30 Update README for Paskia SSO 2026-01-31 05:15:52 +00:00
LeoVasanko 8f76d770ee Fixes for public mode authentication flows. 2026-01-31 04:48:44 +00:00
LeoVasanko 232fd92b22 Finalize Paskia integration and built-in authentication. 2026-01-31 00:47:04 +00:00
LeoVasanko be69164c8f Bundle icons into a single file. 2026-01-30 19:12:27 +00:00
LeoVasanko 4f39875786 TypeScript check and typing corrections. 2026-01-30 19:03:37 +00:00
LeoVasanko 21250a1a2d Upgrade frontend packages, Pinia API changes. 2026-01-30 18:53:25 +00:00
LeoVasanko 849b1a6868 Update scripts with latest fastapi-vue-setup. 2026-01-30 18:40:26 +00:00
LeoVasanko 7be02e951d Consistent dialog styling widgets and using Paskia's shared backdrop. Internal password auth mimics Paskia. API paths changed (/auth goes to internal or paskia depending on config). All API calls and previews get access checks. 2026-01-30 18:28:05 +00:00
LeoVasanko bb38328c24 Improved tooltip display behavior; disabled for touch, less eager to activate on mouse, closer to cursor. 2026-01-22 18:08:50 +00:00
LeoVasanko 146497d731 Fix display of vertical images and videos such they fit in the gallery item. 2026-01-22 02:35:24 +00:00
LeoVasanko 442816a0ae Fix video preview images that were displayed sideways for portrait video. The least bad approach loses HDR but shows in correct orientation. For 0 and 180 degrees we preserve HDR too. 2026-01-22 02:20:39 +00:00
LeoVasanko d32afa6016 Display play button on video previews to indicate it is a video that can be played. 2026-01-22 01:43:50 +00:00
LeoVasanko fa60c962c4 Attempt a better mobile layout, to fix sizing issues with Brave Android. 2026-01-22 00:57:30 +00:00
LeoVasanko e55e11b399 Fix flickering tooltip when hovering modified in FileExplorer. Use the new, improved tooltip also in gallery to show full name, modified and size of an item. 2026-01-22 00:49:50 +00:00
LeoVasanko b6c21152e7 Fix modified tooltip (exact timestamp) flickering on/off because of timestamp updates every second. Made the tooltip follow mouse cursor. 2026-01-22 00:39:26 +00:00
LeoVasanko f354fc5c71 Less aggressive automatic gallery mode switching, only when folder changes. Fixes issues with focus being lost from search. 2026-01-22 00:32:18 +00:00
LeoVasanko 5bda809921 Cleaner folder headers in gallery mode search results. Multi folder results are always grouped by folder (FileExplorer and Gallery), but still otherwise respecting the chosen sort order. Overall this produces a much cleaner layout. 2026-01-22 00:23:23 +00:00
LeoVasanko 2cc92cd786 Deprecation, remove unused import. 2026-01-22 00:16:59 +00:00
LeoVasanko ba6380e71e Add a script to run devserver. Migrate to build and JS utils provided by fastapi-vue. Frontend directory renamed to frontend-build. Update Sanic, deprecations. 2026-01-21 23:32:04 +00:00
LeoVasanko 0d853032bf Cleaner handling when preview generation fails. Using original file as fallback. 2026-01-21 23:29:51 +00:00
LeoVasanko 1cb512e65d Remove overly eager Gallery automode. No longer switches to gallery when merely changing sort column. 2025-10-01 07:23:24 +00:00
LeoVasanko 972aaee9fe Improved reliability of direct to folder downloads. 2025-10-01 06:49:50 +00:00
LeoVasanko 055eaa8a21 Fix admin UI password reset and user deletion functions. 2025-10-01 06:31:20 +00:00
LeoVasanko 05fb81c36d Implement web-based user management / admin setup. (#8)
Implement Admin Settings dialog for user management and toggling the public server flag, not needing CLI for maintenance anymore.
2025-09-30 23:10:33 +00:00
LeoVasanko 6639174e8f Fix Vue TypeScript module declarations for build 2025-10-01 05:32:14 +00:00
LeoVasanko 5e2e71eafb Clean up the remaining uses of print() 2025-08-18 22:49:40 +00:00
LeoVasanko 7e4c5bc911 Use zstd rather than brotli for static file compression. 2025-08-18 04:51:46 +00:00
LeoVasanko bfcce1b80e Fix typing and import in the config file module. 2025-08-17 22:10:55 +00:00
LeoVasanko 2bd8d4a323 Startup banner with version display, and --version, using stderr/stdout properly. 2025-08-17 21:10:43 +00:00
LeoVasanko 1d97d48fed Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 19:21:03 +00:00
LeoVasanko f627890e55 Image previews improved, all EXIF Orientations handled. 2025-08-17 19:00:52 +00:00
LeoVasanko 5a5b47346f Cleanup for release 1.0.0. 2025-08-15 23:11:57 +00:00
LeoVasanko 64de17a74b Maintenance update (#7)
- Use modern tooling uv and bun
- Various changes to work with latest PyAV and PIL that have changed their API
- Improved image, video and document previews (uses AVIF, renders AVIF/HEIC/videos in HDR, faster processing)
- Fix a server hang in some cases where a folder was moved or renamed
- Log exceptions instead of only returning 500 response to client
- Log timing of preview generation functions
- Default to quality 50 in previews (previously 40)
2025-08-15 16:03:04 +00:00
LeoVasanko 55133e82f3 Better transparent Cista image 2023-11-25 17:25:35 +00:00
LeoVasanko 211da1cdda Correct hatch build hook 2023-11-21 20:11:37 +00:00
LeoVasanko d8972cee5d Less messy breadcrumbs on search results in gallery 2023-11-21 17:51:38 +00:00
LeoVasanko 2110284b76 Fix direct uploads and downloads, transfer bar UI 2023-11-21 16:11:54 +00:00
LeoVasanko fa0588837e Fixed New Folder, added Rename to Gallery 2023-11-21 15:49:33 +00:00
LeoVasanko 83bc1bea81 Various build fixes, cleanup and details (#6)
- Major memory usage reduction in video previews
- Finally builds properly on Windows too
2023-11-21 15:32:49 +00:00
LeoVasanko afc6c37d78 Cleanup 2023-11-21 08:35:34 +00:00
LeoVasanko 1d6319d900 Download progress bar 2023-11-21 08:19:23 +00:00
LeoVasanko ec9fda3586 Preserve cursor and selection across reloads. 2023-11-21 06:49:15 +00:00
LeoVasanko 136d2c2421 Keep current cursor when switching gallery/details. 2023-11-21 06:11:05 +00:00
LeoVasanko 27aefc50d4 Fixed renames and breadcrumbs for gallery. 2023-11-21 05:47:41 +00:00
LeoVasanko dd95e5a858 Prune deleted folders off of breadcrumbs' longest visited (keeps current folder even if missing). 2023-11-21 04:18:37 +00:00
LeoVasanko cdb22f9228 Refactor empty folder to a separate component, allow creating new folders in empty folders again. 2023-11-21 04:08:17 +00:00
LeoVasanko 876ecd8978 Don't let global shortcuts interfere with input fields. 2023-11-21 03:45:47 +00:00
LeoVasanko d0ef32c9e9 Further bugfixes. Increase full update interval to 5 minutes. 2023-11-20 19:22:49 +00:00
LeoVasanko 58579b50c4 Rewritten inotify watching and faster full tree traversal (also for polling worker). Bugs in inotify watcher fixed. 2023-11-20 18:49:24 +00:00
LeoVasanko 587268c14b Fix regression: inotify watching was broken. 2023-11-20 13:58:20 +00:00
LeoVasanko ee65f8afb7 Small fixes 2023-11-20 12:57:05 +00:00
LeoVasanko 50642231e5 Avoid showing dialogs at page load. 2023-11-20 12:42:04 +00:00
LeoVasanko dfd4a15cc9 Implement settings dialog and password changes. 2023-11-20 19:26:51 +00:00
LeoVasanko 907fdb9baf Gallery improvements, better layout and autoplay of next media file. 2023-11-20 17:51:34 +00:00
LeoVasanko 4b49f454ba More robust and flexible layout. 2023-11-19 13:55:52 +00:00
LeoVasanko d939c1ddbb Video preview posters 2023-11-19 08:44:46 +00:00
LeoVasanko 66698869d6 Minor changes 2023-11-19 08:03:23 +00:00
LeoVasanko fee66ce7ed Add pymupdf/fitz dependency 2023-11-19 07:21:11 +00:00
LeoVasanko c72e3d91ee Added PDF previews. Preview quality configurable. Preview browser caching and cache busting. 2023-11-19 07:16:24 +00:00
LeoVasanko c5f611bc4c Improve Gallery keyboard navigation 2023-11-19 04:37:48 +00:00
LeoVasanko 6dfb93e0dc Remove new folder button until implemented in gallery 2023-11-19 04:26:38 +00:00
LeoVasanko 9724f1095e Fix theming of filenames in gallery light mode. 2023-11-19 04:25:08 +00:00
LeoVasanko fb565a95d6 Fix preview images that need rotation 2023-11-19 03:56:16 +00:00
LeoVasanko 218f9a5b26 Remove debug 2023-11-19 03:47:01 +00:00
LeoVasanko 09e35acf87 Add PIL dependency 2023-11-19 03:40:34 +00:00
LeoVasanko c84c570d2e Implement media preview thumbnails for Gallery 2023-11-19 03:38:25 +00:00
LeoVasanko a52167bb5e Fix empty folder / folder missing for empty folders at root 2023-11-19 02:22:07 +00:00
LeoVasanko a04b2794d6 Large number of keyboard navigation and other fixes. 2023-11-19 02:15:13 +00:00
LeoVasanko d721de8811 Global shortcut keys tuning. 2023-11-18 23:56:53 +00:00
LeoVasanko 651ce7b6a7 Remove defineProps imports (it is a compiler macro now). 2023-11-18 23:47:55 +00:00
LeoVasanko 64eb9896a9 Fix cursor handling 2023-11-18 23:47:34 +00:00
LeoVasanko f16a738ec2 Search placeholder 2023-11-18 23:39:08 +00:00
LeoVasanko 763991f86e File explorer CSS, avoid modified/size being cut 2023-11-18 23:32:40 +00:00
LeoVasanko 548e67338d Improved breadcrumb scaling 2023-11-18 23:00:55 +00:00
LeoVasanko ea4edc6e4f Added header eye button for gallery toggle 2023-11-18 23:00:46 +00:00
LeoVasanko 1d47397506 Keyboard sort by 1-2-3 supplemented by the key left of them for default sort. 2023-11-18 22:31:26 +00:00
LeoVasanko fc2c961d10 Refactoring cursor to be stored in store as key only. A few issues remain. 2023-11-18 11:44:18 +00:00
LeoVasanko 9895d7bf6c Implemented Gallery view for media files. 2023-11-18 10:32:24 +00:00
LeoVasanko 35a6b2740b Skip any symlinks while scanning. Stats on how long a scan took. 2023-11-18 09:49:35 +00:00
LeoVasanko 1ed4a8db94 Fixed header and breadcrumb layout and issues. 2023-11-18 08:16:53 +00:00
LeoVasanko ffd8086b03 Correct error page rendering via Sanic. 2023-11-18 01:20:14 +00:00
LeoVasanko 0cd7249712 Tidying up log, correct HTTP status for error pages. 2023-11-17 09:10:18 +00:00
LeoVasanko 5deeaae5bd Watching cleanup 2023-11-17 00:24:45 +00:00
LeoVasanko 4613581759 Code cleanup and bugfixes:
- Resolve threading deadlock when multiple watch request arrived at the same moment.
- Implement more graceful server exit.
- Reduce excessive logging.
- Fix unix socket clearing; until Sanic starts accepting Path for unix socket name.
2023-11-16 03:02:44 +00:00
LeoVasanko 890fa6ec06 Update documentation README 2023-11-15 00:23:00 +00:00
LeoVasanko 2d15a0b76c Update frontend/README.md 2023-11-14 23:50:17 +00:00
LeoVasanko 879ad09677 CLI simplification, better diagnostic for missing wwwroot, docs. 2023-11-15 00:03:15 +00:00
LeoVasanko 8bff541297 Updated installation and README 2023-11-15 00:00:21 +00:00
LeoVasanko 14ea4d51f2 Keyboard nav: 1/2/3 globally toggle table ordering. Esc closes search, slash also toggles search like Ctrl/Cmd+F does. 2023-11-14 21:55:55 +00:00
LeoVasanko 0d24d807ac BreadCrumb & keyboard navigation improvements. 2023-11-14 21:43:01 +00:00
LeoVasanko dd4a82aaaa Attempt to fix config handling on Windows 2023-11-14 08:31:35 +00:00
LeoVasanko c5b5e3a76c Add more quit points for watching thread. 2023-11-14 08:28:53 +00:00
LeoVasanko c578f84753 Add frontend build to Python packaging. Remove dead code, cleanup. 2023-11-14 08:19:33 +00:00
LeoVasanko 4c9d802b65 Add project URL 2023-11-14 06:59:22 +00:00
LeoVasanko 479b4a13d0 Update README 2023-11-14 06:45:05 +00:00
LeoVasanko 626afc07fa Remember sort order 2023-11-14 06:11:32 +00:00
LeoVasanko 551dfb61e8 Add file context menu (only rename for now). 2023-11-14 02:09:12 +00:00
LeoVasanko a3e4c50707 Add missing file 2023-11-14 01:55:04 +00:00
LeoVasanko d5cc4ebcb8 Refactoring Document storage (#5)
- Major refactoring that makes Doc a class with properties
- Data made only shallow reactive, for a good speedup of initial load
- Minor bugfixes and UX improvements along the way
- Fixed handling of hash and question marks in URLs (was confusing Vue Router)
- Search made stricter to find good results (not ignore all punctuation)
2023-11-13 17:52:57 +00:00
LeoVasanko 4a2d6c1299 Use localStoragerather than sessionStorage for cache. Rename variable. 2023-11-13 13:04:39 +00:00
LeoVasanko 08a018f382 Log messages on session loading 2023-11-13 12:11:17 +00:00
LeoVasanko 4747c1cb7e Typing error 2023-11-13 12:11:02 +00:00
66 changed files with 6864 additions and 1631 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
.*
*.lock
!.gitignore
__pycache__/
*.egg-info/
/cista/_version.py
/cista/wwwroot/*
/cista/frontend-build/
/dist
+156 -27
View File
@@ -1,27 +1,156 @@
# Web File Storage
Run directly from repository with Hatch (or use pip install as usual):
```sh
hatch run cista -l :3000 /path/to/files
```
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
Create your user account:
```sh
hatch run cista --user admin --privileged
```
## Build frontend
Frontend needs to be built before using and after any frontend changes:
```sh
cd frontend
npm install
npm run build
```
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
# Cista Web Storage
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
## Getting Started
### Running the Server
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
Create an account: (otherwise the server is public for all)
```fish
uvx cista --user yourname --privileged
```
Serve your files at http://localhost:8000:
```fish
uvx cista -l :8000 /path/to/files
```
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
```fish
pip install cista --break-system-packages
```
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
## Authentication
Cista supports three authentication modes:
### Built-in Authentication (default)
User accounts are managed directly by Cista. Create users with the `--user` flag:
```fish
uvx cista --user admin --privileged # Create admin user
uvx cista --user guest # Create regular user
```
Privileged users can manage other users and change settings via the Admin Settings menu.
### Public Mode
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
### Paskia SSO Authentication
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
```fish
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
```
In Paskia mode:
- All `/auth/*` requests are proxied to the Paskia backend
- Users with `cista:login` permission can access files
- Users with `cista:admin` permission get privileged access (Admin Settings)
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
### Internet Access
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
`/etc/caddy/Caddyfile`:
```Caddyfile
cista.example.com {
reverse_proxy :8000
}
```
Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
## System Deployment
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.
Create `/etc/systemd/system/cista@.service`:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always
[Install]
WantedBy=multi-user.target
```
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
```fish
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Public exposure is easiest using the Caddy web server.
`/etc/caddy/Caddyfile`:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```
## Development setup
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
Make sure you have git, uv and bun (or npm) installed.
Backend (Python) setup and run:
```fish
git clone https://git.zi.fi/Vasanko/cista-storage.git
cd cista-storage
uv sync --dev
uv run cista --dev -l :8000 /path/to/files
```
Frontend (Vue/Vite) run the dev server in another terminal:
```fish
cd frontend
bun install
bun run dev
```
Building the package for release (frontend + Python wheel/sdist):
```fish
uv build
```
Vue is used to build files in `cista/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.
+109 -25
View File
@@ -1,3 +1,4 @@
import os
import sys
from pathlib import Path
@@ -9,8 +10,46 @@ from cista.util import pwgen
del app, server80.app # Only import needed, for Sanic multiprocessing
doc = f"""Cista {cista.__version__} - A file storage for the web.
def create_banner():
"""Create a framed banner with the Cista version."""
title = f"Cista {cista.__version__}"
subtitle = "A file storage for the web"
width = max(len(title), len(subtitle)) + 4
return f"""\
{"" * width}
{title:^{width}}
{subtitle:^{width}}
{"" * width}
"""
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
"""Create a framed startup box with server information."""
title = f"Cista {cista.__version__}"
listen = unix if unix else url
location = f"{folder} @ {listen}"
lines = [title, location]
if paskia_url:
lines.append(f"Paskia: {paskia_url}")
if dev:
lines.append("dev mode")
# Calculate width based on content
inner_width = max(len(line) for line in lines) + 2
# Build the box
box = [f"{'' * inner_width}"]
for line in lines:
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n"
banner = create_banner()
doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
@@ -18,20 +57,32 @@ Usage:
Options:
-c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on
:8000 (localhost port, plain http)
:8989 (localhost port, plain http)
<addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket)
example.com (run on 80 and 443 with LetsEncrypt)
--import-droppy Import Droppy config from ~/.droppy/config
--dev Developer mode (reloads, friendlier crashes, more logs)
Listen address, path and imported options are preserved in config, and only
custom config dir and dev mode need to be specified on subsequent runs.
Listen address and path are preserved in config,
and only config dir and dev mode need to be specified on subsequent runs.
User management:
--user NAME Create or modify user
--privileged Give the user full admin rights
--password Reset password
Environment:
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
https://git.zi.fi/leovasanko/paskia
"""
first_time_help = """\
No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts
cista -l :8989 /path/to/files # Run the server on localhost:8989
See cista --help for other options!
"""
@@ -43,11 +94,18 @@ def main():
try:
return _main()
except Exception as e:
print("Error:", e)
sys.stderr.write(f"Error: {e}\n")
return 1
def _main():
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
if any(arg in sys.argv for arg in ("--help", "-h")):
sys.stdout.write(banner)
elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n")
return 0
# Don't print banner yet for normal startup - we'll print the startup box later
args = docopt(doc)
if args["--user"]:
return _user(args)
@@ -62,13 +120,10 @@ def _main():
_confdir(args)
exists = config.conffile.exists()
import_droppy = args["--import-droppy"]
necessary_opts = exists or import_droppy or path and listen
necessary_opts = exists or import_droppy or path
if not necessary_opts:
# Maybe run without arguments
print(doc)
print(
"No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n",
)
sys.stderr.write(first_time_help)
return 1
settings = {}
if import_droppy:
@@ -77,22 +132,33 @@ def _main():
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
)
settings = droppy.readconf()
# Droppy's public flag is kept as-is (same name in our config)
if path:
settings["path"] = path
elif not exists:
settings["path"] = Path.home() / "Downloads"
if listen:
settings["listen"] = listen
operation = config.update_config(settings)
print(f"Config {operation}: {config.conffile}")
elif not exists:
settings["listen"] = ":8989"
config.update_config(settings)
# Prepare to serve
unix = None
url, _ = serve.parse_listen(config.config.listen)
url, opts = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir():
raise ValueError(f"No such directory: {config.config.path}")
extra = f" ({unix})" if unix else ""
dev = args["--dev"]
if dev:
extra += " (dev mode)"
print(f"Serving {config.config.path} at {url}{extra}")
# Check for Paskia SSO
from cista.sso import PASKIA_BACKEND_URL
# Print startup box
startup_box = create_startup_box(
folder=config.config.path,
url=url,
unix=opts.get("unix"),
dev=dev,
paskia_url=PASKIA_BACKEND_URL or None,
)
sys.stderr.write(startup_box)
# Run the server
serve.run(dev=dev)
return 0
@@ -107,16 +173,29 @@ def _confdir(args):
raise ValueError("Config path is not a directory")
# Accidentally pointed to the db.toml, use parent
confdir = confdir.parent
config.conffile = confdir / config.conffile.name
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir() # Uses environ if available
def _user(args):
_confdir(args)
config.load_config()
if config.conffile.exists():
config.load_config()
operation = False
else:
# Defaults for new config when user is created
operation = config.update_config(
{
"listen": ":8989",
"path": Path.home() / "Downloads",
"public": False,
}
)
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
name = args["--user"]
if not name or not name.isidentifier():
raise ValueError("Invalid username")
config.load_config()
u = config.config.users.get(name)
info = f"User {name}" if u else f"New user {name}"
changes = {}
@@ -128,11 +207,16 @@ def _user(args):
info += " (admin)" if oldadmin else ""
if args["--password"] or not u:
changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}"
res = config.update_user(args["--user"], changes)
print(info)
info += f"\n Password: {pw}\n"
res = config.update_user(name, changes)
sys.stderr.write(f"{info}\n")
if res == "read":
print(" No changes")
sys.stderr.write(" No changes\n")
if operation == "created":
sys.stderr.write(
"Now you can run the server:\n cista # defaults set: -l :8989 ~/Downloads\n"
)
if __name__ == "__main__":
+71 -16
View File
@@ -1,11 +1,13 @@
import asyncio
import typing
from pathlib import PurePosixPath
from secrets import token_bytes
import msgspec
from sanic import Blueprint
from sanic import Blueprint, json
from sanic.exceptions import BadRequest
from cista import __version__, config, watching
from cista import __version__, auth, config, sso, watching
from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
@@ -15,12 +17,12 @@ fileserver = FileServer()
@bp.before_server_start
async def start_fileserver(app, _):
async def start_fileserver(app):
await fileserver.start()
@bp.after_server_stop
async def stop_fileserver(app, _):
async def stop_fileserver(app):
await fileserver.stop()
@@ -52,6 +54,9 @@ async def upload(req, ws):
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}")
# Signal the watcher about the uploaded file and its parent directories
path = PurePosixPath(req.name)
watching.notify_change(path, *path.parents)
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
@@ -86,12 +91,36 @@ async def control(req, ws):
while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(cmd)
# Signal the watcher about affected paths
watching.notify_change(*cmd.affected_paths())
await asend(ws, StatusMsg(status="ack", req=cmd))
@bp.websocket("watch")
@websocket_wrapper
async def watch(req, ws):
# Build user info from either built-in auth or SSO
user_info = None
if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode)
try:
await sso.validate_sso_request(req)
except Exception:
pass # Ignore auth errors, user_info stays None
if sso_user := getattr(req.ctx, "sso_user", None):
ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", [])
user_info = {
"username": ctx.get("user", {}).get("display_name", ""),
"privileged": "cista:admin" in perms,
}
elif req.ctx.user:
# Built-in auth: use local user database
user_info = {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
await ws.send(
msgspec.json.encode(
{
@@ -99,25 +128,51 @@ async def watch(req, ws):
"name": config.config.name or config.config.path.name,
"version": __version__,
"public": config.config.public,
"paskia": sso.paskia_enabled(),
},
"user": {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
if req.ctx.user
else None,
"user": user_info,
}
).decode()
)
uuid = token_bytes(16)
try:
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
await ws.send(watching.format_space(watching.state.space))
await ws.send(watching.format_root(watching.state.root))
q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws
)
await ws.send(space)
await ws.send(root)
# Send updates
while True:
await ws.send(await q.get())
except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket
raise
finally:
del watching.pubsub[uuid]
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
def subscribe(uuid, ws):
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
return (
q,
watching.format_space(watching.state.space),
watching.format_root(watching.state.root),
)
@bp.put("config/public")
async def update_public(request):
await auth.verify(request, privileged=True)
try:
public = request.json["public"]
if not isinstance(public, bool):
raise ValueError("public must be a boolean")
except KeyError:
raise BadRequest("Missing public field") from None
except ValueError as e:
raise BadRequest(str(e)) from None
config.update_config({"public": public})
return json({"message": "Public access setting updated", "public": public})
+104 -61
View File
@@ -2,20 +2,22 @@ import asyncio
import datetime
import mimetypes
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw
from sanic.exceptions import Forbidden, NotFound, ServerError
from sanic.log import logging
from sanic import Blueprint, Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor
from cista import auth, config, session, watching
from cista import auth, config, preview, session, sso, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@@ -23,24 +25,41 @@ from cista.util.apphelpers import handle_sanic_exception
sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp)
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
if sso.paskia_enabled():
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
else:
app.blueprint(auth.bp) # Built-in auth routes
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
setproctitle("cista-main")
@app.before_server_start
async def main_start(app, loop):
async def main_start(app):
config.load_config()
await watching.start(app, loop)
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=8, thread_name_prefix="cista-ioworker"
max_workers=preview_workers, thread_name_prefix="cista-preview"
)
# Larger pool for long-running but low-memory zip operations
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
watching.start(app)
@app.after_server_stop
async def main_stop(app, loop):
await watching.stop(app, loop)
# 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)
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
@@ -62,10 +81,23 @@ async def use_session(req):
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
@app.on_response
async def forward_sso_cookies(req, res):
"""Forward Set-Cookie headers from SSO validation to client."""
if cookies := getattr(req.ctx, "sso_cookies", None):
for cookie in cookies:
res.headers.add("set-cookie", cookie)
@app.before_server_start
def http_fileserver(app, _):
def http_fileserver(app):
bp = Blueprint("fileserver")
bp.on_request(auth.verify)
@bp.on_request
async def verify_fileserver(request):
"""Verify access to file server routes."""
await auth.verify(request)
bp.static(
"/files/",
config.config.path,
@@ -81,8 +113,9 @@ www = {}
def _load_wwwroot(www):
wwwnew = {}
base = Path(__file__).with_name("wwwroot")
base = Path(__file__).with_name("frontend-build")
paths = [PurePath()]
zstd = ZstdCompressor(level=18)
while paths:
path = paths.pop(0)
current = base / path
@@ -114,25 +147,33 @@ def _load_wwwroot(www):
else "no-cache",
"content-type": mime,
}
# Precompress with Brotli
br = brotli.compress(data)
if len(br) >= len(data):
br = False
wwwnew[name] = data, br, headers
# Precompress with ZSTD
zs = zstd.compress(data)
if len(zs) >= len(data):
zs = False
wwwnew[name] = data, zs, headers
if not wwwnew:
raise ServerError(
"Web frontend missing. Did you forget npm run build?",
extra={"wwwroot": str(base)},
quiet=True,
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
if not www:
logger.warning(msg)
if not app.debug:
msg = "Web frontend missing. Cista installation is broken.\n"
wwwnew[""] = (
msg.encode(),
False,
{
"etag": "error",
"content-type": "text/plain",
"cache-control": "no-store",
},
)
return wwwnew
@app.before_server_start
async def start(app):
await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot())
if not app.debug:
await load_wwwroot(app)
async def load_wwwroot(app):
@@ -142,46 +183,34 @@ async def load_wwwroot(app):
)
async def refresh_wwwroot():
while True:
await asyncio.sleep(0.5)
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:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print("Error loading wwwroot", e)
if not app.debug:
return
@app.route("/<path:path>", methods=["GET", "HEAD"])
async def wwwroot(req, path=""):
"""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)
if name not in www:
raise NotFound(f"File not found: /{path}", extra={"name": name})
data, br, headers = www[name]
data, zs, headers = www[name]
if req.headers.if_none_match == headers["etag"]:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
# Brotli compressed?
if br and "br" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "br"}
data = br
# Zstandard compressed?
if zs and "zstd" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "zstd"}
data = zs
return raw(data, headers=headers)
@app.route("/favicon.ico", methods=["GET", "HEAD"])
async def favicon(req):
# Browsers keep asking for it when viewing files (not HTML with icon link)
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath()
idx = 0
@@ -207,6 +236,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
await auth.verify(req)
wanted = set(keys.split("+"))
files = get_files(wanted)
@@ -237,27 +267,40 @@ async def zip_download(req, keys, zipfile, ext):
yield chunk
assert size == 0
pending_put = None # Current queue.put future, can be cancelled
def worker():
nonlocal pending_put
try:
for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
pending_put = future
future.result() # Blocks until queue has space
except asyncio.CancelledError:
logger.info("ZIP download cancelled by client disconnect")
except Exception:
logging.exception("Error streaming ZIP")
logger.exception("Error streaming ZIP")
raise
finally:
pending_put = None
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
# Don't block the event loop: run in a thread
# Don't block the event loop: run in a thread (use larger zip pool)
queue = asyncio.Queue(maxsize=1)
loop = asyncio.get_event_loop()
thread = loop.run_in_executor(app.ctx.threadexec, worker)
thread = loop.run_in_executor(app.ctx.zipexec, worker)
# Stream the response
res = await req.respond(
content_type="application/zip",
headers={"cache-control": "no-store"},
)
while chunk := await queue.get():
await res.send(chunk)
try:
while chunk := await queue.get():
await res.send(chunk)
finally:
# Cancel any pending put to unblock and stop the worker
if pending_put:
pending_put.cancel()
await thread # If it raises, the response will fail download
+381 -47
View File
@@ -10,6 +10,175 @@ from sanic import Blueprint, html, json, redirect
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session
from cista.util import pwgen
_LOGIN_PAGE_CSS = """\
/* ===========================================
LOGIN PAGE STYLES
Must match ModalDialog.vue global styles.
=========================================== */
* { box-sizing: border-box; }
body {
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
font-size: 1rem;
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.login-card {
background: #ddd;
color: #000;
border-radius: 0.5rem;
box-shadow: 0 0 1rem #0008;
width: 100%;
max-width: 320px;
}
h1 {
background: #146;
color: #fff;
margin: 0;
padding: 0.5rem 1rem;
font-size: 1.2rem;
font-weight: normal;
border-radius: 0.5rem 0.5rem 0 0;
}
.content {
padding: 1rem;
}
.message {
color: #444;
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
}
form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
}
label {
font-size: 1rem;
}
input[type="text"],
input[type="password"] {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
min-width: 0;
}
input:focus {
outline: none;
border-color: #f80;
}
.button-row {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
}
button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
background: #146;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
button:hover { background: #f80; }
button:disabled {
background: #888;
cursor: not-allowed;
}
.error {
grid-column: 1 / -1;
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
margin: 0;
}
"""
_LOGIN_PAGE_JS = """\
const form = document.getElementById('loginForm');
const error = document.getElementById('error');
const submitBtn = document.getElementById('submitBtn');
const usernameField = document.getElementById('username');
const passwordField = document.getElementById('password');
const isInIframe = window.parent !== window;
// Focus username field on load
usernameField.focus();
const showError = (msg) => {
error.textContent = msg;
submitBtn.disabled = false;
submitBtn.textContent = 'Log in';
// Focus and select the relevant field
if (msg.toLowerCase().includes('password')) {
passwordField.focus();
passwordField.select();
} else {
usernameField.focus();
usernameField.select();
}
};
form.onsubmit = async (e) => {
e.preventDefault();
error.textContent = '';
submitBtn.disabled = true;
submitBtn.textContent = 'Logging in...';
try {
const res = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
username: usernameField.value,
password: passwordField.value
})
});
if (res.ok) {
if (isInIframe) {
window.parent.postMessage({type: 'auth-success'}, '*');
} else {
window.location.href = '/';
}
} else {
const data = await res.json();
showError(data.message || data.detail || 'Login failed');
}
} catch (err) {
showError('Connection error. Please try again.');
}
};
"""
# Import for SSO validation (lazily loaded to avoid circular imports)
_sso_module = None
def _get_sso():
global _sso_module
if _sso_module is None:
from cista import sso
_sso_module = sso
return _sso_module
_argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -62,62 +231,106 @@ class LoginResponse(msgspec.Struct):
error: str = ""
def verify(request, *, privileged=False):
"""Raise Unauthorized or Forbidden if the request is not authorized"""
if privileged:
if request.ctx.user:
if request.ctx.user.privileged:
return
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user:
async def verify(request, *, privileged=False):
"""Verify that the request is authorized.
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
For built-in mode, checks session-based authentication.
For public mode (config.public=True), skips auth unless privileged is required.
Args:
request: The Sanic request object
privileged: If True, requires admin privileges (always enforced even in public mode)
Raises:
Unauthorized: If authentication is required
Forbidden: If access is denied
"""
# Public mode: skip auth unless privileged access is required
if config.config.public and not privileged:
return
raise Unauthorized("Login required", "cookie", quiet=True)
sso = _get_sso()
if sso.paskia_enabled():
perm = "cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm)
return
bp = Blueprint("auth")
@bp.get("/login")
async def login_page(request):
doc = Document("Cista Login")
with doc.div(id="login"):
with doc.form(method="POST", autocomplete="on"):
doc.h1("Login")
doc.input(
name="username",
placeholder="Username",
autocomplete="username",
required=True,
).br
doc.input(
type="password",
name="password",
placeholder="Password",
autocomplete="current-password",
required=True,
).br
doc.input(type="submit", value="Login")
s = session.get(request)
if s:
name = s["username"]
with doc.form(method="POST", action="/logout"):
doc.input(type="submit", value=f"Logout {name}")
flash = request.cookies.message
if flash:
doc.dialog(
flash,
id="flash",
open=True,
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
user = getattr(request.ctx, "user", None)
if privileged:
if user and user.privileged:
return
raise Forbidden(
"Access Forbidden: Only for privileged users",
quiet=True,
)
if user:
return
raise Unauthorized(
f"Login required for {request.path}",
"cookie",
context={"auth": {"iframe": "/auth/restricted"}},
quiet=True,
)
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
bp = Blueprint("auth", url_prefix="/auth")
@bp.get("/restricted")
async def login_page(request):
"""Login page that works both standalone and in paskia iframe."""
s = session.get(request)
# Check if already logged in
if s:
# Already authenticated - signal success if in iframe
return html(_login_success_page(s["username"]))
doc = Document("Cista - Login")
# Add paskia-compatible styling and scripts
doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"):
doc.h1("Authentication Required")
with doc.div(class_="content"):
with doc.form(method="POST", id="loginForm", autocomplete="on"):
doc.label("Username:", for_="username")
doc.input(
type="text",
id="username",
name="username",
autocomplete="username webauthn",
required=True,
)
doc.label("Password:", for_="password")
doc.input(
type="password",
id="password",
name="password",
autocomplete="current-password webauthn",
required=True,
)
with doc.div(class_="button-row"):
doc.button("Log in", type="submit", id="submitBtn")
doc.p("", class_="error", id="error")
# JavaScript for AJAX login and postMessage communication
doc.script_(_LOGIN_PAGE_JS)
res = html(doc)
if flash:
res.cookies.delete_cookie("flash")
if s is False:
session.delete(res)
return res
def _login_success_page(username: str) -> str:
"""Minimal page that signals auth-success to parent iframe."""
return str(
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
)
@bp.post("/login")
async def login_post(request):
try:
@@ -148,7 +361,7 @@ async def login_post(request):
return res
@bp.post("/logout")
@bp.post("/api/logout")
async def logout_post(request):
s = request.ctx.session
msg = "Logged out" if s else "Not logged in"
@@ -159,3 +372,124 @@ async def logout_post(request):
res = json({"message": msg})
session.delete(res)
return res
@bp.post("/password-change")
async def change_password(request):
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
pwchange = request.json["passwordChange"]
password = request.json["password"]
else:
username = request.form["username"][0]
pwchange = request.form["passwordChange"][0]
password = request.form["password"][0]
if not username or not password:
raise KeyError
except KeyError:
raise BadRequest(
"Missing username, passwordChange or password",
) from None
try:
user = login(username, password)
set_password(user, pwchange)
except ValueError as e:
raise Forbidden(str(e), context={"redirect": "/login"}) from e
if "text/html" in request.headers.accept:
res = redirect("/")
session.flash(res, "Password updated")
else:
res = json({"message": "Password updated"})
session.create(res, username)
return res
@bp.get("/users")
async def list_users(request):
await verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
{
"username": name,
"privileged": user.privileged,
"lastSeen": user.lastSeen,
}
)
return json({"users": users})
@bp.post("/users")
async def create_user(request):
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
password = request.json.get("password")
privileged = request.json.get("privileged", False)
else:
username = request.form["username"][0]
password = request.form.get("password", [None])[0]
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
if not username or not username.isidentifier():
raise ValueError("Invalid username")
except (KeyError, ValueError) as e:
raise BadRequest(str(e)) from e
if username in config.config.users:
raise BadRequest("User already exists")
if not password:
password = pwgen.generate()
changes = {"privileged": privileged}
changes["hash"] = _argon.hash(_pwnorm(password))
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} created", "password": password})
@bp.put("/users/<username>")
async def update_user(request, username):
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
else:
changes = {}
if "password" in request.form:
changes["password"] = request.form["password"][0]
if "privileged" in request.form:
changes["privileged"] = request.form["privileged"][0].lower() == "true"
except KeyError as e:
raise BadRequest("Missing fields") from e
password_response = None
if "password" in changes:
if changes["password"] == "":
changes["password"] = pwgen.generate()
password_response = changes["password"]
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
del changes["password"]
if not changes:
return json({"message": "No changes"})
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
response = {"message": f"User {username} updated"}
if password_response:
response["password"] = password_response
return json(response)
@bp.delete("/users/<username>")
async def delete_user(request, username):
await verify(request, privileged=True)
if username not in config.config.users:
raise BadRequest("User does not exist")
try:
config.del_user(username)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"})
+71 -22
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
import os
import secrets
import sys
from contextlib import suppress
from functools import wraps
from hashlib import sha256
from pathlib import Path, PurePath
from time import time
from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec
import msgspec
import msgspec.toml
class Config(msgspec.Struct):
@@ -19,6 +24,13 @@ class Config(msgspec.Struct):
links: dict[str, Link] = {}
# Typing: arguments for config-modifying functions
P = ParamSpec("P")
ResultStr = Literal["modified", "created", "read"]
RawModifyFunc = Callable[Concatenate[Config, P], Config]
ModifyPublic = Callable[P, ResultStr]
class User(msgspec.Struct, omit_defaults=True):
privileged: bool = False
hash: str = ""
@@ -31,8 +43,24 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0
config = None
conffile = Path.home() / ".local/share/cista/db.toml"
# Global variables - initialized during application startup
config: Config
conffile: Path
def init_confdir() -> None:
global conffile
if p := os.environ.get("CISTA_HOME"):
home = Path(p)
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
home = (
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
)
if not home.is_dir():
home.mkdir(parents=True, exist_ok=True)
home.chmod(0o700)
conffile = home / "db.toml"
def derived_secret(*params, len=8) -> bytes:
@@ -58,10 +86,10 @@ def dec_hook(typ, obj):
raise TypeError
def config_update(modify):
def config_update(
modify: RawModifyFunc,
) -> ResultStr | Literal["collision"]:
global config
if not conffile.exists():
conffile.parent.mkdir(parents=True, exist_ok=True)
tmpname = conffile.with_suffix(".tmp")
try:
f = tmpname.open("xb")
@@ -75,12 +103,8 @@ def config_update(modify):
old = conffile.read_bytes()
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError:
# No existing config file, make sure we have a folder...
confdir = conffile.parent
confdir.mkdir(parents=True, exist_ok=True)
confdir.chmod(0o700)
old = b""
c = None
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
c = modify(c)
new = msgspec.toml.encode(c, enc_hook=enc_hook)
if old == new:
@@ -90,6 +114,10 @@ def config_update(modify):
return "read"
f.write(new)
f.close()
if sys.platform == "win32":
# Windows doesn't support atomic replace
with suppress(FileNotFoundError):
conffile.unlink()
tmpname.rename(conffile) # Atomic replace
except:
f.close()
@@ -99,17 +127,23 @@ def config_update(modify):
return "modified" if old else "created"
def modifies_config(modify):
"""Decorator for functions that modify the config file"""
def modifies_config(
modify: Callable[Concatenate[Config, P], Config],
) -> Callable[P, ResultStr]:
"""Decorator for functions that modify the config file
The decorated function takes as first arg Config and returns it modified.
The wrapper handles atomic modification and returns a string indicating the result.
"""
@wraps(modify)
def wrapper(*args, **kwargs):
def m(c):
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
def m(c: Config) -> Config:
return modify(c, *args, **kwargs)
# Retry modification in case of write collision
while (c := config_update(m)) == "collision":
time.sleep(0.01)
sleep(0.01)
return c
return wrapper
@@ -117,14 +151,23 @@ def modifies_config(modify):
def load_config():
global config
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
init_confdir()
raw = conffile.read_bytes()
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
# Migrate from old authentication field if present
raw_dict = msgspec.toml.decode(raw)
if "authentication" in raw_dict and "public" not in raw_dict:
# Old config with authentication mode: migrate to public bool
new_public = raw_dict["authentication"] == "none"
config = msgspec.structs.replace(config, public=new_public)
update_config({}) # Save the migrated config
@modifies_config
def update_config(conf: Config, changes: dict) -> Config:
"""Create/update the config with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings.update(changes)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
@@ -134,7 +177,12 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
"""Create/update a user with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config
try:
u = conf.users[name].__copy__()
# Copy user by converting to dict and back
u = msgspec.convert(
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
User,
dec_hook=dec_hook,
)
except KeyError:
u = User()
if "password" in changes:
@@ -152,6 +200,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
@modifies_config
def del_user(conf: Config, name: str) -> Config:
"""Delete named user account."""
ret = conf.__copy__()
ret.users.pop(name)
return ret
# Create a copy by converting to dict and back
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"].pop(name)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
+267
View File
@@ -0,0 +1,267 @@
import asyncio
import gc
import io
import mimetypes
import urllib.parse
from pathlib import PurePosixPath
from time import perf_counter
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import av
import fitz # PyMuPDF
import numpy as np
import pillow_heif
from PIL import Image
from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound
from sanic.log import logger
from cista import auth, config
from cista.util.filename import sanitize
pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview")
@bp.on_request
async def verify_preview(request):
"""Verify access to preview routes."""
await auth.verify(request)
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}
@bp.get("/<path:path>")
async def preview(req, path):
"""Preview a file"""
maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path)))
filepath = config.config.path / rel
stat = filepath.lstat()
etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex()
savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = {
"etag": etag,
"last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable"
+ ("" if config.config.public else ", private"),
"content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
if req.headers.if_none_match == etag:
# 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)
def dispatch(path, quality, maxsize, maxzoom):
try:
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
type, _ = mimetypes.guess_type(path.name)
if type and type.startswith("video/"):
return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e:
logger.warning(f"Cannot generate preview for {path}: {e}")
except Exception as e:
logger.exception(f"Error generating preview for {path}: {e}")
def process_image(path, *, maxsize, quality):
t_load = perf_counter()
with Image.open(path) as img:
# Force decode to include I/O in load timing
img.load()
t_proc = perf_counter()
# Resize
w, h = img.size
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
proc_ms = (t_save - t_proc) * 1000
save_ms = (t_end - t_save) * 1000
logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
path.name,
load_ms,
proc_ms,
save_ms,
)
return ret
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
t_load_start = perf_counter()
pdf = fitz.open(path)
page = pdf.load_page(page_number)
w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
t_load_end = perf_counter()
t_save_start = perf_counter()
ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1)
t_save_end = perf_counter()
logger.debug(
"Preview pdf %s: load+render=%.1fms save=%.1fms",
path.name,
(t_load_end - t_load_start) * 1000,
(t_save_end - t_save_start) * 1000,
)
return ret
def process_video(path, *, maxsize, quality):
frame = None
imgdata = io.BytesIO()
istream = ostream = icc = occ = frame = None
t_load_start = perf_counter()
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
t_load_end = t_load_start
t_save_start = t_load_start
t_save_end = t_load_start
with (
av.open(str(path)) as icontainer,
av.open(imgdata, "w", format="avif") as ocontainer,
):
istream = icontainer.streams.video[0]
istream.codec_context.skip_frame = "NONKEY"
icontainer.seek((icontainer.duration or 0) // 8)
for frame in icontainer.decode(istream):
if frame.dts is not None:
break
else:
raise RuntimeError("No frames found in video")
# Resize frame to thumbnail size
if frame.width > maxsize or frame.height > maxsize:
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
new_width = int(frame.width * scale_factor)
new_height = int(frame.height * scale_factor)
frame = frame.reformat(width=new_width, height=new_height)
# Apply EXIF rotation if present
if frame.rotation:
# frame.rotation indicates clockwise rotation needed to display correctly
# np.rot90 rotates counter-clockwise, so we negate k
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
if k == 2:
# 180° rotation can be done in YUV420p, preserving HDR
try:
fplanes = frame.to_ndarray()
# Split into Y, U, V planes of proper dimensions
planes = [
fplanes[: frame.height],
fplanes[
frame.height : frame.height + frame.height // 4
].reshape(frame.height // 2, frame.width // 2),
fplanes[frame.height + frame.height // 4 :].reshape(
frame.height // 2, frame.width // 2
),
]
# Rotate each plane by 180°
planes = [np.rot90(p, 2) for p in planes]
# Restore PyAV format
planes = np.hstack([p.flat for p in planes]).reshape(
-1, planes[0].shape[1]
)
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
del planes, fplanes
except Exception as e:
logger.exception(f"Error rotating video frame by 180°: {e}")
elif k in (1, 3):
# 90° or 270° rotation requires RGB conversion (loses HDR)
try:
rgb = frame.to_ndarray(format="rgb24")
rgb = np.rot90(rgb, k)
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
frame = frame.reformat(
format="yuv420p"
) # Convert back for encoding
del rgb
except Exception as e:
logger.exception(
f"Error rotating video frame by {frame.rotation}°: {e}"
)
t_load_end = perf_counter()
t_save_start = perf_counter()
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
ostream = ocontainer.add_stream(
"av1",
options={
"crf": crf,
"usage": "realtime",
"cpu-used": "8",
"threads": "1",
},
)
assert isinstance(ostream, av.VideoStream)
ostream.width = frame.width
ostream.height = frame.height
ostream.pix_fmt = frame.format.name
icc = istream.codec_context
occ = ostream.codec_context
# Copy HDR metadata from input video stream
occ.color_primaries = icc.color_primaries
occ.color_trc = icc.color_trc
occ.colorspace = icc.colorspace
occ.color_range = icc.color_range
ocontainer.mux(ostream.encode(frame))
ocontainer.mux(ostream.encode(None)) # Flush the stream
t_save_end = perf_counter()
# Capture frame dimensions before cleanup
ret = imgdata.getvalue()
logger.debug(
"Preview video %s: load+decode=%.1fms save=%.1fms",
path.name,
(t_load_end - t_load_start) * 1000,
(t_save_end - t_save_start) * 1000,
)
del imgdata, istream, ostream, icc, occ, frame
gc.collect()
return ret
+34 -16
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import shutil
from pathlib import PurePosixPath
from typing import Any
import msgspec
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self):
raise NotImplementedError
def affected_paths(self) -> list[str]:
"""Return list of paths affected by this operation for change notification."""
return []
class MkDir(ControlBase):
path: str
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=True, exist_ok=False)
def affected_paths(self) -> list[str]:
return [filename.sanitize(self.path)]
class Rename(ControlBase):
path: str
@@ -36,6 +44,11 @@ class Rename(ControlBase):
path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to))
def affected_paths(self) -> list[str]:
sanitized = filename.sanitize(self.path)
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
return [sanitized, new_path]
class Rm(ControlBase):
sel: list[str]
@@ -49,6 +62,9 @@ class Rm(ControlBase):
else:
p.unlink()
def affected_paths(self) -> list[str]:
return [filename.sanitize(p) for p in self.sel]
class Mv(ControlBase):
sel: list[str]
@@ -63,6 +79,13 @@ class Mv(ControlBase):
for p in sel:
shutil.move(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
paths = [filename.sanitize(p) for p in self.sel]
# Include new locations in dst
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
return paths
class Cp(ControlBase):
sel: list[str]
@@ -86,6 +109,11 @@ class Cp(ControlBase):
else:
shutil.copy2(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
# Only destinations are new (sources unchanged)
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
ControlTypes = MkDir | Rename | Rm | Mv | Cp
@@ -112,7 +140,7 @@ class ErrorMsg(msgspec.Struct):
## Directory listings
class FileEntry(msgspec.Struct, array_like=True):
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
level: int
name: str
key: str
@@ -120,12 +148,14 @@ class FileEntry(msgspec.Struct, array_like=True):
size: int
isfile: int
def __repr__(self):
def __str__(self):
return self.key or "FileEntry()"
def __repr__(self):
return f"{self.name} ({self.size}, {self.mtime})"
class Update(msgspec.Struct, array_like=True):
...
class Update(msgspec.Struct, array_like=True): ...
class UpdKeep(Update, tag="k"):
@@ -149,15 +179,3 @@ class Space(msgspec.Struct):
free: int
usage: int
storage: int
def make_dir_data(root):
if len(root) == 3:
return FileEntry(*root)
id_, size, mtime, listing = root
converted = {}
for name, data in listing.items():
converted[name] = make_dir_data(data)
sz = sum(x.size for x in converted.values())
mt = max(x.mtime for x in converted.values())
return DirEntry(id_, sz, max(mt, mtime), converted)
+22 -12
View File
@@ -2,6 +2,7 @@ import os
import re
from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic
from cista import config, server80
@@ -26,7 +27,6 @@ def run(*, dev=False):
motd=False,
dev=dev,
auto_reload=dev,
reload_dir={confdir},
access_log=True,
) # type: ignore
if dev:
@@ -45,18 +45,28 @@ def check_cert(certdir, domain):
def parse_listen(listen):
if listen.startswith("/"):
unix = Path(listen).resolve()
# Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
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():
raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/",
)
return "http://localhost", {"unix": unix}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
try:
addr, _port = listen.split(":", 1)
port = int(_port)
except Exception:
raise ValueError(f"Invalid listen address: {listen}") from None
return f"http://localhost:{port}", {"host": addr, "port": port}
return "http://localhost", {"unix": unix.as_posix()}
host, port = ep["host"], ep["port"]
# When binding all interfaces, use single_listener=False for Sanic
if len(endpoints) > 1:
return f"http://localhost:{port}", {
"host": host,
"port": port,
"single_listener": False,
}
return f"http://{host}:{port}", {"host": host, "port": port}
+324
View File
@@ -0,0 +1,324 @@
"""SSO (paskia) authentication proxy and validation module.
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
- All /auth/* requests are proxied to the paskia backend
Environment variables:
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
Must include scheme (http/https), no trailing slash
"""
import asyncio
import os
import re
import httpx
import websockets
from sanic import Blueprint
from sanic.exceptions import Forbidden, SanicException, Unauthorized
from sanic.log import logger
# Auth backend URL for SSO validation (from env, no trailing slash)
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
# Validate and set PASKIA_BACKEND_URL
if _raw_url:
if not re.match(r"^https?://[^\s/]+$", _raw_url):
raise ValueError(
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
"must be http(s)://host[:port] with no path or trailing slash"
)
PASKIA_BACKEND_URL = _raw_url
else:
PASKIA_BACKEND_URL = ""
def paskia_enabled() -> bool:
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
return bool(PASKIA_BACKEND_URL)
# Shared httpx client for SSO requests (reused for connection pooling)
_client: httpx.AsyncClient | None = None
async def get_client() -> httpx.AsyncClient:
"""Get or create the shared httpx client."""
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=1.0)
return _client
async def close_client():
"""Close the shared httpx client."""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
"""Validate an SSO request against the auth backend.
Args:
request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin)
Returns:
User info dict if valid, None if validation fails with auth required response
Raises:
Forbidden: If access is denied (403)
Unauthorized: If authentication is required (401)
"""
if not paskia_enabled():
return None
client = await get_client()
headers = {}
if "host" in request.headers:
headers["host"] = request.headers["host"]
if "cookie" in request.headers:
headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"]
headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
try:
response = await client.post(
url,
headers=headers,
)
if response.status_code == 200:
try:
data = response.json()
request.ctx.sso_user = data
if "set-cookie" in response.headers:
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
return data
except Exception:
request.ctx.sso_user = {}
return {}
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text or "Authentication error"}
if response.status_code == 401:
if "auth" in error_data and "iframe" in error_data["auth"]:
error_data["auth"]["iframe"] += "&theme=light"
raise Unauthorized(
error_data.get("detail", "Authentication required"),
"cookie",
context=error_data,
quiet=True,
)
elif response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
context=error_data,
quiet=True,
)
else:
detail = error_data.get("detail", "")
logger.warning(
f"SSO validation {url} returned {response.status_code}: {detail}"
)
raise Forbidden(
detail or "Authentication error",
context=error_data,
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"SSO validation {url} network error: {e}")
raise SanicException(
"Authentication service unavailable",
status_code=502,
quiet=True,
)
async def proxy_auth_request(request):
"""Proxy a request to the auth backend.
All requests under /auth/ are proxied when paskia mode is enabled.
"""
client = await get_client()
path = request.path
query_string = request.query_string
url = f"{PASKIA_BACKEND_URL}{path}"
if query_string:
url = f"{url}?{query_string}"
skip_headers = {
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
}
headers = [
(key, value)
for key, value in request.headers.items()
if key.lower() not in skip_headers
]
headers.append(("x-forwarded-for", request.client_ip))
headers.append(("x-forwarded-host", request.host))
headers.append(("x-forwarded-proto", request.scheme))
try:
async with client.stream(
method=request.method,
url=url,
headers=headers,
content=request.body if request.body else None,
) as response:
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
resp_hop_by_hop = {
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
}
resp_headers = [
(key, value)
for key, value in response.headers.multi_items()
if key.lower() not in resp_hop_by_hop
]
from sanic import raw as raw_response
return raw_response(
raw_content,
status=response.status_code,
headers=resp_headers,
content_type=response.headers.get("content-type", "application/json"),
)
except httpx.RequestError as e:
logger.error(f"Auth proxy request failed: {e}")
from sanic import json
return json(
{"detail": "Authentication service unavailable", "error": str(e)},
status=503,
)
async def proxy_auth_websocket(request, ws):
"""Proxy a WebSocket connection to the auth backend."""
path = request.path
query_string = request.query_string
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
"https://", "wss://"
)
url = f"{ws_backend}{path}"
if query_string:
url = f"{url}?{query_string}"
additional_headers = {}
if "cookie" in request.headers:
additional_headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
additional_headers["authorization"] = request.headers["authorization"]
if "origin" in request.headers:
additional_headers["origin"] = request.headers["origin"]
if "user-agent" in request.headers:
additional_headers["user-agent"] = request.headers["user-agent"]
additional_headers["x-forwarded-for"] = request.ip
additional_headers["x-forwarded-host"] = request.host
additional_headers["x-forwarded-proto"] = request.scheme
try:
async with websockets.connect(
url, additional_headers=additional_headers
) as backend_ws:
async def forward_to_backend():
try:
async for message in ws:
await backend_ws.send(message)
except Exception:
pass
async def forward_to_client():
try:
async for message in backend_ws:
await ws.send(message)
except Exception:
pass
await asyncio.gather(
forward_to_backend(),
forward_to_client(),
return_exceptions=True,
)
except Exception as e:
logger.error(f"WebSocket proxy to {url} failed: {e}")
def _is_websocket_request(request) -> bool:
"""Check if the request is a WebSocket upgrade request."""
connection = request.headers.get("connection", "").lower()
upgrade = request.headers.get("upgrade", "").lower()
connection_tokens = [t.strip() for t in connection.split(",")]
return "upgrade" in connection_tokens and upgrade == "websocket"
async def _handle_websocket_upgrade(request):
"""Handle WebSocket upgrade and proxy the connection."""
protocol = request.transport.get_protocol()
ws = await protocol.websocket_handshake(request, subprotocols=None)
await proxy_auth_websocket(request, ws)
# Blueprint for auth proxy routes (only registered when paskia_enabled())
bp = Blueprint("sso", url_prefix="/auth")
@bp.route(
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
)
async def auth_proxy(request, path=""):
"""Proxy all auth requests to the auth backend."""
if _is_websocket_request(request):
await _handle_websocket_upgrade(request)
from sanic import empty
return empty()
return await proxy_auth_request(request)
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def auth_proxy_root(request):
"""Proxy root auth requests to the auth backend."""
if _is_websocket_request(request):
await _handle_websocket_upgrade(request)
from sanic import empty
return empty()
return await proxy_auth_request(request)
+10 -5
View File
@@ -21,7 +21,6 @@ def jres(data, **kwargs):
async def handle_sanic_exception(request, e):
logger.exception(e)
context, code = {}, 500
message = str(e)
if isinstance(e, SanicException):
@@ -30,10 +29,15 @@ async def handle_sanic_exception(request, e):
if not message or not request.app.debug and code == 500:
message = "Internal Server Error"
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
if code == 500:
logger.exception(e)
# Non-browsers get JSON errors
if "text/html" not in request.headers.accept:
# Include auth context if present (for SSO auth required responses)
# Auth must be at top level for paskia library to detect it
response_data = {"code": code, "message": message, "detail": message, **context}
return jres(
ErrorMsg({"code": code, "message": message, **context}),
response_data,
status=code,
)
# Redirections flash the error message via cookies
@@ -42,7 +46,7 @@ async def handle_sanic_exception(request, e):
res.cookies.add_cookie("message", message, max_age=5)
return res
# Otherwise use Sanic's default error page
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
def websocket_wrapper(handler):
@@ -51,16 +55,17 @@ def websocket_wrapper(handler):
@wraps(handler)
async def wrapper(request, ws, *args, **kwargs):
try:
auth.verify(request)
await auth.verify(request)
await handler(request, ws, *args, **kwargs)
except Exception as e:
logger.exception(e)
context, code, message = {}, 500, str(e) or "Internal Server Error"
if isinstance(e, SanicException):
context = e.context or {}
code = e.status_code
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
if not getattr(e, "quiet", False) or code == 500:
logger.exception(f"{code} {e!r}")
raise
return wrapper
+2 -2
View File
@@ -1,4 +1,5 @@
from time import monotonic
from typing import Callable
class LRUCache:
@@ -12,7 +13,7 @@ class LRUCache:
cache (list): Internal list storing the cache items.
"""
def __init__(self, open: callable, *, capacity: int, maxage: float):
def __init__(self, open: Callable, *, capacity: int, maxage: float):
"""
Initialize LRUCache.
@@ -50,7 +51,6 @@ class LRUCache:
# Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic()))
self.expire_items()
print(self.cache)
return f
def expire_items(self):
+599 -222
View File
@@ -1,15 +1,17 @@
import asyncio
import queue
import shutil
import stat
import sys
import threading
import time
from contextlib import suppress
from os import stat_result
from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG
import msgspec
from natsort import humansorted, natsort_keygen, ns
from sanic.log import logging
from sanic.log import logger
from cista import config
from cista.fileio import fuid
@@ -23,7 +25,7 @@ class State:
def __init__(self):
self.lock = threading.RLock()
self._space = Space(0, 0, 0, 0)
self._listing: list[FileEntry] = []
self.root: list[FileEntry] = []
@property
def space(self):
@@ -35,280 +37,304 @@ class State:
with self.lock:
self._space = space
@property
def root(self) -> list[FileEntry]:
with self.lock:
return self._listing[:]
@root.setter
def root(self, listing: list[FileEntry]):
with self.lock:
self._listing = listing
def treeiter(rootmod):
relpath = PurePosixPath()
for i, entry in enumerate(rootmod):
if entry.level > 0:
relpath = PurePosixPath(*relpath.parts[: entry.level - 1]) / entry.name
yield i, relpath, entry
def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]):
relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0)
begin, end = 0, len(self._listing)
level = 0
isfile = 0
# Special case for root
if not relpath.parts:
return slice(begin, end)
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
begin = None
ret = []
begin += 1
for part in relpath.parts:
for i, relpath, entry in treeiter(rootmod):
if begin is None:
if relpath == path:
begin = i
ret.append(entry)
continue
if entry.level <= len(path.parts):
break
ret.append(entry)
return begin, ret
def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
# Find the first entry greater than the new one
# precondition: the new entry doesn't exist
isfile = 0
level = 0
i = 0
for i, rel, entry in treeiter(rootmod):
if entry.level > level:
# We haven't found item at level, skip subdirectories
continue
if entry.level < level:
# We have passed the level, so the new item is the first
return i
if level == 0:
# root
level += 1
found = False
continue
while begin < end:
entry = self._listing[begin]
ename = rel.parts[level - 1]
name = relpath.parts[level - 1]
if entry.level < level:
break
esort = sortkey(ename)
nsort = sortkey(name)
# Non-leaf are always folders, only use relfile at leaf
isfile = relfile if len(relpath.parts) == level else 0
if entry.level == level:
if entry.name == part:
found = True
if level == len(relpath.parts):
isfile = relfile
else:
begin += 1
break
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
if cmp > 0:
break
# First compare by isfile, then by sorting order and if that too matches then case sensitive
cmp = (
entry.isfile - isfile
or (esort > nsort) - (esort < nsort)
or (ename > name) - (ename < name)
)
begin += 1
if cmp > 0:
return i
if cmp < 0:
continue
if not found:
return slice(begin, begin)
level += 1
if level > len(relpath.parts):
logger.error(
f"insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}"
)
break
else:
i += 1
# Found the starting point, now find the end of the slice
for end in range(begin + 1, len(self._listing) + 1):
if end == len(self._listing) or self._listing[end].level <= level:
break
return slice(begin, end)
def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]):
with self.lock:
return self._listing[self._slice(index)]
def __setitem__(
self, index: tuple[PurePosixPath, int], value: list[FileEntry]
) -> None:
rel, isfile = index
with self.lock:
if rel.parts:
parent = self._slice(rel.parent)
if parent.start == parent.stop:
raise ValueError(
f"Parent folder {rel.as_posix()} is missing for {rel.name}"
)
self._listing[self._slice(index)] = value
def __delitem__(self, relpath: PurePosixPath):
with self.lock:
del self._listing[self._slice(relpath)]
def _index(self, rel: PurePosixPath):
idx = 0
ret = []
def _dir(self, idx: int):
level = self._listing[idx].level + 1
end = len(self._listing)
idx += 1
ret = []
while idx < end and (r := self._listing[idx]).level >= level:
if r.level == level:
ret.append(idx)
return ret, idx
def update(self, rel: PurePosixPath, value: FileEntry):
begin = 0
parents = []
while self._listing[begin].level < len(rel.parts):
parents.append(begin)
return i
state = State()
rootpath: Path = None # type: ignore
quit = False
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
quit = threading.Event()
# Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
def watcher_thread(loop):
global rootpath
import inotify.adapters
while True:
rootpath = config.config.path
i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
new = walk()
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
# The watching is not entirely reliable, so do a full refresh every minute
refreshdl = time.monotonic() + 60.0
for event in i.event_gen():
if quit:
return
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
break
# Do a full refresh?
if time.monotonic() > refreshdl:
break
if event is None:
continue
_, flags, path, filename = event
if not any(f in modified_flags for f in flags):
continue
# Update modified path
path = PurePosixPath(path) / filename
try:
update(path.relative_to(rootpath), loop)
except Exception as e:
print("Watching error", e, path, rootpath)
raise
i = None # Free the inotify object
def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets."""
for path in paths:
if isinstance(path, str):
path = PurePosixPath(path)
# Convert absolute paths to relative (strip leading /)
if path.is_absolute():
path = (
PurePosixPath(*path.parts[1:])
if len(path.parts) > 1
else PurePosixPath()
)
# Skip root paths (empty, '.') to avoid full tree walks
if not path.parts or path.parts == (".",):
continue
_update_queue.put(path)
def watcher_thread_poll(loop):
global rootpath
while not quit:
rootpath = config.config.path
new = walk()
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
time.sleep(2.0)
## Filesystem scanning
def walk(rel=PurePosixPath()) -> list[FileEntry]: # noqa: B008
path = rootpath / rel
try:
st = path.stat()
except OSError:
return []
return _walk(rel, int(not stat.S_ISDIR(st.st_mode)), st)
def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
isfile=isfile,
)
if isfile:
return [entry]
ret = [entry]
def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]:
path = rootpath / rel
ret = []
try:
st = stat or path.stat()
isfile = int(not S_ISDIR(st.st_mode))
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
isfile=isfile,
)
if isfile:
return [entry]
# Walk all entries of the directory
ret: list[FileEntry] = [...] # type: ignore
li = []
for f in path.iterdir():
if quit.is_set():
raise SystemExit("quit")
if f.name.startswith("."):
continue # No dotfiles
s = f.stat()
li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s))
for [isfile, name, s] in humansorted(li):
subtree = _walk(rel / name, isfile, s)
child = subtree[0]
entry.mtime = max(entry.mtime, child.mtime)
entry.size += child.size
ret.extend(subtree)
with suppress(FileNotFoundError):
s = f.lstat()
isfile = S_ISREG(s.st_mode)
isdir = S_ISDIR(s.st_mode)
if not isfile and not isdir:
continue
li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li):
sub = walk(rel / name, stat=s)
child = sub[0]
entry = FileEntry(
level=entry.level,
name=entry.name,
key=entry.key,
size=entry.size + child.size,
mtime=max(entry.mtime, child.mtime),
isfile=entry.isfile,
)
ret.extend(sub)
except FileNotFoundError:
pass # Things may be rapidly in motion
except OSError as e:
print("OS error walking path", path, e)
if e.errno == 13: # Permission denied
pass
logger.error(f"Watching {path=}: {e!r}")
if ret:
ret[0] = entry
return ret
def update(relpath: PurePosixPath, loop):
"""Called by inotify updates, check the filesystem and broadcast any changes."""
if rootpath is None or relpath is None:
print("ERROR", rootpath, relpath)
def update_root(loop):
"""Full filesystem scan"""
old = state.root
new = walk(PurePosixPath())
if old != new:
update = format_update(old, new)
with state.lock:
broadcast(update, loop)
state.root = new
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
"""Called on FS updates, check the filesystem and broadcast any changes."""
new = walk(relpath)
with state.lock:
old = state[relpath]
if old == new:
return
old = state.root
if new:
state[relpath, new[0].isfile] = new
else:
del state[relpath]
broadcast(format_update(old, state.root), loop)
obegin, old = treeget(rootmod, relpath)
if old == new:
return
if obegin is not None:
del rootmod[obegin : obegin + len(old)]
if new:
i = treeinspos(rootmod, relpath, new[0].isfile)
rootmod[i:i] = new
def update_space(loop):
"""Called periodically to update the disk usage."""
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
# Update only on difference above 1 MB
tol = 10**6
old = msgspec.structs.astuple(state.space)
new = msgspec.structs.astuple(space)
if any(abs(o - n) > tol for o, n in zip(old, new, strict=True)):
state.space = space
broadcast(format_space(space), loop)
## Messaging
def format_update(old, new):
# Make keep/del/insert diff until one of the lists ends
oidx, nidx = 0, 0
oremain, nremain = set(old), set(new)
update = []
keep_count = 0
iteration_count = 0
# Precompute index maps to allow deterministic tie-breaking when both
# candidates exist in both sequences but are not equal (rename/move cases)
old_pos = {e: i for i, e in enumerate(old)}
new_pos = {e: i for i, e in enumerate(new)}
while oidx < len(old) and nidx < len(new):
iteration_count += 1
# Emergency brake for potential infinite loops
if iteration_count > 50000:
logger.error(
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
)
raise Exception(
f"format_update infinite loop detected at iteration {iteration_count}"
)
modified = False
# Matching entries are kept
if old[oidx] == new[nidx]:
entry = old[oidx]
oremain.discard(entry)
nremain.discard(entry)
keep_count += 1
oidx += 1
nidx += 1
continue
if keep_count > 0:
modified = True
update.append(UpdKeep(keep_count))
keep_count = 0
# Items only in old are deleted
del_count = 0
rest = new[nidx:]
while oidx < len(old) and old[oidx] not in rest:
while oidx < len(old) and old[oidx] not in nremain:
oremain.remove(old[oidx])
del_count += 1
oidx += 1
if del_count:
update.append(UpdDel(del_count))
continue
# Items only in new are inserted
insert_items = []
rest = old[oidx:]
while nidx < len(new) and new[nidx] not in rest:
insert_items.append(new[nidx])
while nidx < len(new) and new[nidx] not in oremain:
entry = new[nidx]
nremain.discard(entry)
insert_items.append(entry)
nidx += 1
update.append(UpdIns(insert_items))
if insert_items:
modified = True
update.append(UpdIns(insert_items))
if not modified:
# Tie-break: both items exist in both lists but don't match here.
# Decide whether to delete old[oidx] first or insert new[nidx] first
# based on which alignment is closer.
if oidx >= len(old) or nidx >= len(new):
break
cur_old = old[oidx]
cur_new = new[nidx]
pos_old_in_new = new_pos.get(cur_old)
pos_new_in_old = old_pos.get(cur_new)
# Default distances if not present (shouldn't happen if in remain sets)
dist_del = (pos_old_in_new - nidx) if pos_old_in_new is not None else 1
dist_ins = (pos_new_in_old - oidx) if pos_new_in_old is not None else 1
# Prefer the operation with smaller forward distance; tie => delete
if dist_del <= dist_ins:
# Delete current old item
oremain.discard(cur_old)
update.append(UpdDel(1))
oidx += 1
else:
# Insert current new item
nremain.discard(cur_new)
update.append(UpdIns([cur_new]))
nidx += 1
# Diff any remaining
if keep_count > 0:
update.append(UpdKeep(keep_count))
if oidx < len(old):
update.append(UpdDel(len(old) - oidx))
elif nidx < len(new):
if oremain:
update.append(UpdDel(len(oremain)))
elif nremain:
update.append(UpdIns(new[nidx:]))
return msgspec.json.encode({"update": update}).decode()
@@ -323,29 +349,380 @@ def format_root(root):
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):
client_count = 0
try:
for queue in pubsub.values():
queue.put_nowait(msg)
client_count += 1
except Exception:
# Log because asyncio would silently eat the error
logging.exception("Broadcast error")
logger.exception("Broadcast error")
return client_count
async def start(app, loop):
## Watcher thread
class PathIndex:
"""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]
elif children:
# Insert after last child's subtree
last_idx = children[-1]
last_entry = self.root[last_idx]
if last_entry.isfile:
return last_idx + 1
# Find end of last child's subtree
last_path = parent / last_entry.name
if last_path in self._index:
s, c = self._index[last_path]
return s + c
return last_idx + 1
return start
def apply_update(
self, path: PurePosixPath, new_entries: list[FileEntry]
) -> list[FileEntry]:
"""Apply an update and return the new root. Rebuilds index."""
start, old_entries = self.get(path)
if old_entries == new_entries:
return self.root
new_root = self.root[:]
if start is not None:
del new_root[start : start + len(old_entries)]
if new_entries:
# Rebuild index on modified list to find insert pos
self.root = new_root
self._rebuild()
insert_pos = self.find_insert_pos(path, new_entries[0].isfile)
new_root[insert_pos:insert_pos] = new_entries
self.root = new_root
self._rebuild()
return new_root
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
"""Remove child paths if parent is in set."""
if not paths:
return paths
# Filter out root paths (empty or '.') which would cause full tree walks
paths = {p for p in paths if p.parts and p.parts != (".",)}
if not paths:
return set()
# Sort by depth (fewest parts first)
sorted_paths = sorted(paths, key=lambda p: len(p.parts))
result = set()
for path in sorted_paths:
# Check if any ancestor is already in result
is_child = False
for i in range(len(path.parts)):
ancestor = PurePosixPath(*path.parts[:i]) if i > 0 else PurePosixPath()
if ancestor in result:
is_child = True
break
if not is_child:
result.add(path)
return result
# Debounce settings
DEBOUNCE_DELAY = 0.01 # Wait 10ms after last event
DEBOUNCE_MAX = 0.1 # But no more than 100ms total
def watcher(loop):
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
use_inotify = sys.platform == "linux"
inotify_tree = None
modified_flags = frozenset()
if use_inotify:
import inotify.adapters
modified_flags = frozenset(
(
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
)
while not quit.is_set():
if use_inotify:
import inotify.adapters
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
update_root(loop)
path_index = PathIndex(state.root[:])
trefresh = time.monotonic() + 300.0
tspace = time.monotonic() + 5.0
# Pending changes: path -> {"ws": count, "inotify": count}
dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
first_event_time: float | None = None
last_event_time: float | None = None
def add_dirty(path: PurePosixPath, source: str) -> 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 quit.is_set():
now = time.monotonic()
# Full refresh every 300s
if now >= trefresh:
break
# Disk usage update every 5s
if now >= tspace:
tspace = now + 5.0
update_space(loop)
# Check if we should flush pending changes
should_flush = False
if dirty_paths:
time_since_last = now - last_event_time if last_event_time else 0
time_since_first = now - first_event_time if first_event_time else 0
if (
time_since_last >= DEBOUNCE_DELAY
or time_since_first >= DEBOUNCE_MAX
):
should_flush = True
if should_flush:
paths_to_process = dirty_paths.copy()
dirty_paths.clear()
first_event_time = None
last_event_time = None
# Collapse paths (remove children if parent present)
collapsed = collapse_paths(set(paths_to_process.keys()))
# Process each collapsed path
new_root = path_index.root
for path in collapsed:
new_entries = walk(path)
new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed
if new_root != state.root:
try:
update_msg = format_update(state.root, new_root)
with state.lock:
broadcast(update_msg, loop)
state.root = new_root
except Exception:
logger.exception("format_update failed; full rescan")
try:
fresh = walk(PurePosixPath())
path_index = PathIndex(fresh)
update_msg = format_update(state.root, fresh)
with state.lock:
broadcast(update_msg, loop)
state.root = fresh
except Exception:
logger.exception("Fallback failed; sending full root")
with state.lock:
broadcast(format_root(fresh), loop)
state.root = fresh
# Collect events from websocket signals (non-blocking)
try:
while True:
path = _update_queue.get_nowait()
add_dirty(path, "ws")
except queue.Empty:
pass
# Collect inotify events if available (short timeout for responsiveness)
if inotify_tree:
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
if quit.is_set():
return
if not (modified_flags & set(event[1])):
continue
# Extract relative path
path = PurePosixPath(event[2]) / event[3]
try:
rel_path = path.relative_to(rootpath)
except ValueError:
continue
# Skip dotfiles
if any(part.startswith(".") for part in rel_path.parts):
continue
add_dirty(rel_path, "inotify")
# Don't block too long collecting events
now = time.monotonic()
if first_event_time and now - first_event_time >= DEBOUNCE_MAX:
break
else:
# No inotify, just sleep briefly for responsiveness
time.sleep(0.05)
if inotify_tree:
del inotify_tree
def start(app):
global rootpath
config.load_config()
use_inotify = False and sys.platform == "linux"
rootpath = config.config.path
app.ctx.watcher = threading.Thread(
target=watcher_thread if use_inotify else watcher_thread_poll,
args=[loop],
target=watcher,
args=[app.loop],
# Descriptive name for system monitoring
name=f"cista-watcher {rootpath}",
)
app.ctx.watcher.start()
async def stop(app, loop):
global quit
quit = True
def stop(app):
quit.set()
app.ctx.watcher.join()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+2
View File
@@ -0,0 +1,2 @@
audit=false
fund=false
+46 -40
View File
@@ -1,40 +1,46 @@
# cista-front
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
# Cista Vue Frontend
The frontend is a Single-Page App implemented with Vue 3. Development uses the Vite server together with the main Python backend, but in production the latter also serves the prebuilt frontend files.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Hot-Reload for Development
### Run the backend
```fish
uv sync --dev
uv run cista --dev -l :8000
```
### And the Vite server (in another terminal)
```fish
cd frontend
bun install
bun run dev
```
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
### Type-Check, Compile and Minify for Production
This is also called by `uv build` during Python packaging:
```fish
bun run build
```
+6
View File
@@ -1 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+2 -3
View File
@@ -2,11 +2,10 @@
<html lang=en>
<meta charset=UTF-8>
<title>Cista Storage</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<link rel="icon" href="/src/assets/logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script type="module" src="/src/main.ts"></script>
<div id="app"></div>
<body id="app">
+34 -31
View File
@@ -12,41 +12,44 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@imengyu/vue3-context-menu": "^1.3.3",
"@vueuse/core": "^10.4.1",
"esbuild": "^0.19.5",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"unplugin-vue-components": "^0.25.2",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
"@imengyu/vue3-context-menu": "^1.5.3",
"@vueuse/core": "^14.1.0",
"esbuild": "^0.27.2",
"lodash": "^4.17.23",
"lodash-es": "^4.17.23",
"paskia": "^0.1.2",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"unplugin-vue-components": "^31.0.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.27",
"vue-router": "^5.0.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node18": "^18.2.2",
"@types/jsdom": "^21.1.3",
"@types/lodash-es": "^4.17.10",
"@types/node": "^18.17.17",
"@vitejs/plugin-vue": "^4.3.4",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0",
"@rushstack/eslint-patch": "^1.15.0",
"@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0",
"@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/tsconfig": "^0.8.1",
"babel-eslint": "^10.1.0",
"eslint": "^8.52.0",
"eslint-plugin-vue": "^9.18.1",
"jsdom": "^22.1.0",
"npm-run-all2": "^6.0.6",
"prettier": "^3.0.3",
"typescript": "~5.2.0",
"vite": "^4.4.9",
"vitest": "^0.34.4",
"vue-tsc": "^1.8.11"
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4"
},
"prettier": {
"semi": false,
+123 -43
View File
@@ -1,14 +1,25 @@
<template>
<LoginModal />
<div v-if="store.toast" class="toast-message" @click="store.clearToast()">
{{ store.toast }}
</div>
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
{{ store.error }}
</div>
<SettingsModal />
<UserManagementModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" tabindex="-1"/>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
<BreadCrumb :path="path.pathList" primary />
</header>
<main>
<RouterView :path="path.pathList" :query="path.query" />
</main>
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<HeaderSelected :path="path.pathList" />
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer>
</template>
<script setup lang="ts">
@@ -17,29 +28,33 @@ import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
interface Path {
path: string
pathList: string[]
query: string
}
const documentStore = useDocumentStore()
const store = useMainStore()
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = p[0].split('/').filter(value => value !== '')
const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
const query = p.slice(1).join('//')
return {
path: p[0],
path: p[0] ?? '',
pathList,
query
}
})
watchEffect(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage'
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
})
onMounted(loadSession)
onMounted(watchConnect)
@@ -48,70 +63,96 @@ const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = documentStore.fileExplorer as any
if (store.dialog) {
if (timer) {
clearTimeout(timer)
timer = null
}
return
}
const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup'
if (event.repeat) {
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
(c && event.code === 'Space')
) {
event.preventDefault()
if (!input) event.preventDefault()
}
return
}
//console.log("key pressed", event)
// For up/down implement custom fast repeat
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
/// Long if-else machina for all keys we handle here
let arrow = ''
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
}
// Select all (toggle); keydown to prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
// Search also on / (UNIX style)
else if (!input && keyup && event.key === '/') {
headerMain.value!.toggleSearchInput()
}
// Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') {
store.error = ''
store.clearToast()
headerMain.value!.clearSearch(event)
store.focusBreadcrumb()
}
else if (!input && keyup && event.key === 'Backspace') {
Router.back()
}
// Select all (toggle); keydown to precede and prevent builtin
else if (!input && !keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll()
}
// Keys 1-3 to sort columns
// G toggles Gallery
else if (!input && keyup && event.key === 'g') {
store.prefs.gallery = !store.prefs.gallery
}
// Keys Backquote-1-2-3 to sort columns
else if (
c &&
!input &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
) {
fileExplorer.toggleSortColumn(+event.key)
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
}
// Rename
else if (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()
}
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (c && event.code === 'Space') {
else if (!input && c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
} else return
event.preventDefault()
if (!vert) {
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
return
}
if (!timer) {
else return
/// We are handling this!
event.preventDefault()
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
let f: any
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
}
if (f && !keyup) {
// Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey
fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
const t0 = 200, tr = event.altKey ? 20 : 100
f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
}
}
onMounted(() => {
@@ -124,3 +165,42 @@ onUnmounted(() => {
})
export type { Path }
</script>
<style>
/* Toast notifications - fixed at top center of viewport */
.toast-message {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
padding: 0.75rem 1.5rem;
background: var(--accent-color);
color: #000;
font-weight: bold;
border-radius: 0.25rem;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
cursor: pointer;
max-width: 90vw;
text-align: center;
}
.toast-message.status {
background: #555;
color: #fff;
}
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
z-index: 50;
}
footer > * {
justify-content: center;
}
</style>
+46 -91
View File
@@ -14,7 +14,7 @@
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: calc(6.5 * var(--header-font-size));
--header-height: 4rem;
}
@media (prefers-color-scheme: dark) {
:root {
@@ -37,59 +37,20 @@
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 2000px) {
:root {
--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 .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) {
/* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .breadcrumb {
flex-shrink: 1;
}
header .breadcrumb > * {
flex-shrink: 1;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
}
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
}
@media print {
:root {
@@ -98,10 +59,10 @@
--header-background: none;
--header-color: black;
}
nav,
.headermain,
.menu,
.rename-button {
display: none;
display: none !important;
}
.breadcrumb > a {
color: black !important;
@@ -116,16 +77,32 @@
}
.breadcrumb svg {
fill: black !important;
margin: 0 .5rem 0 1rem !important;
}
body#app {
position: static !important;
height: auto !important;
}
main {
height: auto !important;
padding-bottom: 0 !important;
}
thead tr {
font-size: 1rem !important;
position: static !important;
background: none !important;
border-bottom: 1pt solid black !important;
}
audio::-webkit-media-controls-timeline,
video::-webkit-media-controls-timeline {
display: none;
}
audio::-webkit-media-controls,
video::-webkit-media-controls {
display: none;
}
tr, figure {
page-break-inside: avoid;
}
.selection {
min-width: 0 !important;
padding: 0 !important;
@@ -142,14 +119,13 @@
left: 0;
}
}
* {
box-sizing: border-box;
}
html {
font-size: var(--root-font-size);
overflow: hidden;
}
/* Hide scrollbar for all browsers */
main::-webkit-scrollbar {
display: none;
}
main {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@@ -160,12 +136,18 @@ body {
font-family: 'Roboto';
color: var(--primary-color);
margin: 0;
/* Prevent any scrolling on body */
overflow: hidden;
/* Fallback for older browsers */
height: 100vh;
height: 100dvh;
}
tbody .size,
tbody .modified {
font-family: 'Roboto Mono';
}
header {
flex: 0 0 auto;
background-color: var(--header-background);
color: var(--header-color);
font-size: var(--header-font-size);
@@ -207,62 +189,35 @@ table {
border: 0;
gap: 0;
}
#app {
height: 100%;
body#app {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}
main {
flex: 1 1 auto;
min-height: 0; /* Allow flex child to shrink below content size */
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
text-align: center;
}
header nav.headermain {
/* Position so that tooltips can appear on top of other positioned elements */
position: relative;
z-index: 100;
}
main {
height: calc(100svh - var(--header-height));
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
}
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
position: absolute;
font-size: 1rem;
text-align: center;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
}
.modified [data-tooltip]:hover:after {
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
}
@keyframes appearbriefly {
from {
opacity: 0;
}
30% {
opacity: 0;
}
40% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
opacity: 0;
}
}
.error-message {
padding: .5em;
font-weight: bold;
background: var(--accent-color);
color: #000;
}
.ghost {
opacity: 0.5;
}
+137
View File
@@ -0,0 +1,137 @@
// SVG icon index - all icons bundled together
import AddFile from './add-file.svg'
import AddFolder from './add-folder.svg'
import Arrow from './arrow.svg'
import ArrowsH from './arrows-h.svg'
import ArrowsV from './arrows-v.svg'
import Check from './check.svg'
import Code from './code.svg'
import Cog from './cog.svg'
import Copy from './copy.svg'
import CreateFile from './create-file.svg'
import CreateFolder from './create-folder.svg'
import Cross from './cross.svg'
import Disk from './disk.svg'
import Download from './download.svg'
import Exclamation from './exclamation.svg'
import Eye from './eye.svg'
import Find from './find.svg'
import Fullscreen from './fullscreen.svg'
import Github from './github.svg'
import Home from './home.svg'
import Info from './info.svg'
import Link from './link.svg'
import Logo from './logo.svg'
import Loop from './loop.svg'
import Menu from './menu.svg'
import Next from './next.svg'
import Open from './open.svg'
import Paste from './paste.svg'
import Pause from './pause.svg'
import Pencil from './pencil.svg'
import Play from './play.svg'
import Plus from './plus.svg'
import Previous from './previous.svg'
import Reload from './reload.svg'
import Rename from './rename.svg'
import Scissors from './scissors.svg'
import Shuffle from './shuffle.svg'
import Signin from './signin.svg'
import Signout from './signout.svg'
import Skip from './skip.svg'
import Spinner from './spinner.svg'
import Stop from './stop.svg'
import Trash from './trash.svg'
import Triangle from './triangle.svg'
import Unfullscreen from './unfullscreen.svg'
import UpArrow from './up-arrow.svg'
import UploadCloud from './upload-cloud.svg'
import UserCog from './user-cog.svg'
import User from './user.svg'
import VolumeHigh from './volume-high.svg'
import VolumeLow from './volume-low.svg'
import VolumeMedium from './volume-medium.svg'
import VolumeMute from './volume-mute.svg'
import WindowCross from './window-cross.svg'
import Window from './window.svg'
import Wordwrap from './wordwrap.svg'
import Zoomin from './zoomin.svg'
import Zoomout from './zoomout.svg'
// Named exports for direct imports
export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
Disk, Download, Exclamation, Eye, Find, Fullscreen,
Github, Home, Info, Link, Logo, Loop, Menu,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
Zoomin, Zoomout
}
// Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = {
'add-file': AddFile,
'add-folder': AddFolder,
'arrow': Arrow,
'arrows-h': ArrowsH,
'arrows-v': ArrowsV,
'check': Check,
'code': Code,
'cog': Cog,
'copy': Copy,
'create-file': CreateFile,
'create-folder': CreateFolder,
'cross': Cross,
'disk': Disk,
'download': Download,
'exclamation': Exclamation,
'eye': Eye,
'find': Find,
'fullscreen': Fullscreen,
'github': Github,
'home': Home,
'info': Info,
'link': Link,
'logo': Logo,
'loop': Loop,
'menu': Menu,
'next': Next,
'open': Open,
'paste': Paste,
'pause': Pause,
'pencil': Pencil,
'play': Play,
'plus': Plus,
'previous': Previous,
'reload': Reload,
'rename': Rename,
'scissors': Scissors,
'shuffle': Shuffle,
'signin': Signin,
'signout': Signout,
'skip': Skip,
'spinner': Spinner,
'stop': Stop,
'trash': Trash,
'triangle': Triangle,
'unfullscreen': Unfullscreen,
'up-arrow': UpArrow,
'upload-cloud': UploadCloud,
'user-cog': UserCog,
'user': User,
'volume-high': VolumeHigh,
'volume-low': VolumeLow,
'volume-medium': VolumeMedium,
'volume-mute': VolumeMute,
'window-cross': WindowCross,
'window': Window,
'wordwrap': Wordwrap,
'zoomin': Zoomin,
'zoomout': Zoomout,
} as const
export type IconName = keyof typeof icons
@@ -0,0 +1,46 @@
<template>
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
<div class="modal-dialog" id="accessdenied">
<div class="modal-content access-denied">
<p class="icon"></p>
<p class="message">Access Denied</p>
<button @click="reload" class="button">Reload</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop } from 'paskia'
import { watchEffect } from 'vue'
const store = useMainStore()
const reload = () => {
location.reload()
}
// Keep backdrop active when this dialog shows
watchEffect(() => {
if (store.dialog === 'accessdenied') {
holdGlobalBackdrop()
}
})
</script>
<style scoped>
.access-denied {
text-align: center;
padding: 2rem !important;
}
.access-denied .icon {
font-size: 4rem;
margin: 0 0 1rem 0;
}
.access-denied .message {
font-size: 1.5rem;
font-weight: bold;
margin: 0 0 1.5rem 0;
}
</style>
+76 -25
View File
@@ -2,16 +2,24 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@focus="move(0)"
@keydown.left.stop="move(-1)"
@keydown.right.stop="move(1)"
@keyup.enter="move(0)"
@focus=focusCurrent
tabindex=0
>
<a href="#/"
:ref="el => setLinkRef(0, el)"
class="home"
:class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)"
@click.prevent="navigate(0)"
@mouseenter="homeTooltip?.startHover"
@mousemove="homeTooltip?.updatePosition"
@mouseleave="homeTooltip?.endHover"
>
<component :is="home" />
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a>
<template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
@@ -19,38 +27,64 @@
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)"
>{{ location }}</a>
@mouseenter="pathTooltips.get(index)?.startHover"
@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>
</nav>
</template>
<script setup lang="ts">
import home from '@/assets/svg/home.svg'
import { onBeforeUpdate, ref, watchEffect } from 'vue'
import { Home } from '@/assets/svg'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
import CursorTooltip from './CursorTooltip.vue'
const home = Home
const router = useRouter()
const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { 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<{
path: Array<string>
primary?: boolean
}>()
const longest = ref<Array<string>>([])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
const focusCurrent = () => {
nextTick(() => {
const index = props.path.length
if (index < links.length) links[index]!.focus()
})
}
const navigate = (index: number) => {
const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = `/${longest.value.slice(0, index).join('/')}/`
const here = `/${longest.value.join('/')}/`
link.focus()
if (here.startsWith(location.hash.slice(1))) router.replace(url)
else router.push(url)
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
// Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
else router.push(u)
}
const move = (dir: number) => {
@@ -62,13 +96,25 @@ const move = (dir: number) => {
watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
// Navigated out of previous path, reset longest to current
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
})
watchEffect(() => {
if (links.length) navigate(props.path.length)
else {
// Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) {
longest.value = longest.value.slice(0, i)
break
}
}
}
// If needed, focus primary navigation to new location
if (props.primary) nextTick(() => {
const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent()
})
})
</script>
@@ -83,31 +129,36 @@ watchEffect(() => {
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
flex: 1 1 auto;
display: flex;
list-style: none;
min-width: 20%;
max-width: 100%;
min-height: 2em;
margin: 0;
padding: 0 1em 0 0;
overflow: hidden;
}
.breadcrumb > a {
flex: 0 4 auto;
display: flex;
align-items: center;
margin: 0 -0.5em 0 -0.5em;
padding: 0;
max-width: 8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.5em;
color: var(--breadcrumb-color);
padding: 0.3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb a:first-child {
margin-left: 0;
padding-left: .2em;
.breadcrumb > a:first-child {
flex: 0 0 auto;
padding-left: 1.5em;
padding-right: 1.7em;
clip-path: none;
}
.breadcrumb a:last-child {
max-width: none;
.breadcrumb > a:last-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -118,7 +169,7 @@ watchEffect(() => {
0 0
);
}
.breadcrumb a:only-child {
.breadcrumb > a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -130,9 +181,9 @@ watchEffect(() => {
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
padding-left: 0.8em;
width: 1.3em;
height: 1.3em;
margin: -.5em;
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
@@ -152,6 +203,6 @@ watchEffect(() => {
}
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
.breadcrumb a.current { color: var(--accent-color) }
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
.breadcrumb a.current svg { fill: var(--accent-color) }
</style>
+169
View File
@@ -0,0 +1,169 @@
<template>
<Teleport to="body">
<div v-if="visible" ref="tooltipEl" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot>
</div>
</Teleport>
</template>
<script lang="ts">
// Global activation state - shared across all instances
let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
// Track recent touch to suppress touch-triggered mouse events
let lastTouchTime = 0
</script>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
text: string
delay?: number
}>()
const visible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const tooltipEl = ref<HTMLElement | null>(null)
let settleTimer: ReturnType<typeof setTimeout> | null = null
let lastMoveX = 0
let lastMoveY = 0
// Movement threshold (pixels) - cursor must settle within this radius
const SETTLE_THRESHOLD = 8
const tooltipStyle = computed(() => {
// Constrain to viewport
const pad = 8
let x = mouseX.value
let y = mouseY.value
// Only constrain if we've measured the tooltip
if (tooltipWidth.value > 0 && tooltipHeight.value > 0) {
// Adjust horizontal position if tooltip would overflow right edge
if (x + tooltipWidth.value + pad > window.innerWidth) {
x = window.innerWidth - tooltipWidth.value - pad
}
// Adjust vertical position if tooltip would overflow bottom edge
if (y + tooltipHeight.value + pad > window.innerHeight) {
y = window.innerHeight - tooltipHeight.value - pad
}
// Don't go past left/top edges
x = Math.max(pad, x)
y = Math.max(pad, y)
}
return {
left: `${x}px`,
top: `${y}px`,
}
})
// Track touch events globally to suppress touch-simulated mouse events
const onTouchStart = () => { lastTouchTime = Date.now() }
onMounted(() => document.addEventListener('touchstart', onTouchStart, { passive: true }))
onUnmounted(() => document.removeEventListener('touchstart', onTouchStart))
// Check if event is likely from touch (touch happened within last 500ms)
const isTouchEvent = () => Date.now() - lastTouchTime < 500
const showTooltip = () => {
visible.value = true
globalActive = true
// Measure tooltip after it renders
requestAnimationFrame(() => {
if (tooltipEl.value) {
tooltipWidth.value = tooltipEl.value.offsetWidth
tooltipHeight.value = tooltipEl.value.offsetHeight
}
})
}
const scheduleTooltip = () => {
if (settleTimer) clearTimeout(settleTimer)
if (globalDeactivateTimer) {
clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = null
}
const delay = globalActive ? 0 : (props.delay ?? 900)
settleTimer = setTimeout(showTooltip, delay)
}
const startHover = (e: MouseEvent) => {
// Ignore touch-simulated mouse events
if (isTouchEvent()) return
mouseX.value = e.clientX
mouseY.value = e.clientY
lastMoveX = e.clientX
lastMoveY = e.clientY
}
const updatePosition = (e: MouseEvent) => {
// Ignore touch-simulated mouse events
if (isTouchEvent()) return
mouseX.value = e.clientX
mouseY.value = e.clientY
// If tooltip is already visible, just update position
if (visible.value) return
const dx = e.clientX - lastMoveX
const dy = e.clientY - lastMoveY
const distance = Math.sqrt(dx * dx + dy * dy)
// If cursor moved beyond threshold, reset settle timer
if (distance > SETTLE_THRESHOLD) {
lastMoveX = e.clientX
lastMoveY = e.clientY
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
}
// Schedule tooltip when cursor settles
if (!settleTimer) {
scheduleTooltip()
}
}
const endHover = () => {
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
visible.value = false
// Deactivate global state after a short delay if no new tooltip started
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = setTimeout(() => {
globalActive = false
}, 400)
}
defineExpose({
startHover,
updatePosition,
endHover,
})
</script>
<style scoped>
.cursor-tooltip {
position: fixed;
z-index: 10000;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: #fff;
pointer-events: none;
font-size: 1rem;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<SvgButton name="download" tooltip="Download" @click="download" />
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document'
import { zipName } from '@/utils/fileutil'
const store = useMainStore()
const status_init = {
total: 0,
xfer: 0,
t0: 0,
tlast: 0,
statbytes: 0,
statdur: 0,
files: [] as string[],
filestart: 0,
fileidx: 0,
filecount: 0,
filename: '',
filesize: 0,
filepos: 0,
status: 'idle',
}
store.dprogress = {...status_init}
setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) {
// Reset
store.dprogress.statbytes = 0
store.dprogress.statdur = 1
} else {
// Running average by decay
store.dprogress.statbytes *= .9
store.dprogress.statdur *= .9
}
}, 100)
const statReset = () => {
Object.assign(store.dprogress, status_init)
store.dprogress.t0 = Date.now()
store.dprogress.tlast = store.dprogress.t0 + 1
}
const cancelDownloads = () => {
location.reload() // FIXME
}
const linkdl = (href: string) => {
const a = document.createElement('a')
a.href = href
a.download = ''
a.click()
}
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = ''
let h = handle
console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) {
if (doc.dir) continue
store.dprogress.files.push(rel)
++store.dprogress.filecount
store.dprogress.total += doc.size
}
for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories
if (hdir && !rel.startsWith(hdir + '/')) {
hdir = ''
h = handle
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
if (!dir) continue
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) {
console.error('Failed to create directory', hdir, error)
throw new Error(`Failed to create directory ${hdir}: ${error}`)
}
console.log('Created', hdir)
}
if (doc.dir) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC')
// Download file
let fileHandle
try {
fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error)
throw new Error(`Failed to create file ${hdir + name}: ${error}`)
}
try {
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await apiFetch(url)
if (!res.ok) {
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
}
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
}
}
await writable.close()
console.log('Saved', hdir + name)
} catch (error) {
console.error('Failed to write file', hdir + name, error)
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
}
}
statReset()
}
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
if (sel.keys.length === 0) {
store.showToast('No existing files selected')
store.selected.clear()
return
}
// Single file: direct download
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0]![1]}`)
}
// Alt+click: download to folder (hidden feature)
if (e.altKey && 'showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
await filesystemdl(sel, handle)
store.selected.clear()
} catch (e) {
console.error('Download to folder failed', e)
store.showToast('Download to folder failed')
}
return
}
// Default: ZIP download
zipdl(sel)
}
</script>
<style scoped>
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p>
<p v-else-if="store.documentCount === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!exists(props.path)">Folder not found</p>
<p v-else>Empty folder</p>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { Cog } from '@/assets/svg'
import { exists } from '@/utils/fileutil'
const cog = Cog
const store = useMainStore()
const props = defineProps<{
path: string[],
documents: Document[],
}>()
</script>
<style scoped>
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
svg.cog {
width: 10rem;
height: 10rem;
margin: 0 auto;
animation: rotate 10s linear infinite;
filter: drop-shadow(0 0 1rem black);
fill: #888;
}
svg.cog.stopped {
animation: none;
}
</style>
+199 -122
View File
@@ -5,9 +5,9 @@
<th class="selection">
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
</th>
<th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th>
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
<th class="menu"></th>
</tr>
</thead>
@@ -17,30 +17,30 @@
<td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td>
<FileModified :doc=editing />
<FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr>
<template v-for="(doc, index) in sortedDocuments" :key="doc.key">
<template v-for="(doc, index) in documents" :key="doc.key">
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr>
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
@click="cursor = cursor === doc ? null : doc"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<input
type="checkbox"
tabindex="-1"
:checked="documentStore.selected.has(doc.key)"
:checked="store.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? documentStore.selected.add(doc.key)
: documentStore.selected.delete(doc.key)
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
"
/>
</td>
@@ -49,22 +49,16 @@
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<a
:href="url_for(doc)"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a
>
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊</button>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc />
<FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex="-1" @click.stop="contextMenu($event, doc)"></button>
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
</td>
</tr>
</template>
@@ -75,32 +69,27 @@
</tr>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, onMounted, onUnmounted } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document'
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS'
import { collator, formatSize, formatUnixDate } from '@/utils'
import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
const props = defineProps<{
path: Array<string>
documents: Document[]
documents: Doc[]
}>()
const documentStore = useDocumentStore()
const store = useMainStore()
const router = useRouter()
const url_for = (doc: Document) => {
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return doc.dir ? `#/${p}/` : `/files/${p}`
}
const cursor = ref<Document | null>(null)
// File rename
const editing = ref<Document | null>(null)
const rename = (doc: Document, newName: string) => {
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
@@ -124,75 +113,71 @@ const rename = (doc: Document, newName: string) => {
}
doc.name = newName // We should get an update from watch but this is quicker
}
const sortedDocuments = computed(() => sorted(props.documents as Document[]))
const showFolderBreadcrumb = (i: number) => {
const docs = sortedDocuments.value
const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
}
defineExpose({
newFolder() {
const now = Date.now() / 1000
editing.value = {
console.log("New folder")
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
sizedisp: formatSize(0),
modified: formatUnixDate(now),
haystack: '',
}
console.log("New")
})
store.cursor = editing.value.key
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const columns = ['', 'name', 'modified', 'size', '']
toggleSort(columns[column])
},
isCursor() {
return cursor.value !== null && editing.value === null
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = cursor.value
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const doc = cursor.value
if (!doc) return
if (documentStore.selected.has(doc.key)) {
documentStore.selected.delete(doc.key)
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
documentStore.selected.add(doc.key)
store.selected.add(key)
}
this.cursorMove(1)
this.cursorMove(1, null)
},
cursorMove(d: number, select = false) {
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() },
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click()
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const documents = sortedDocuments.value
if (documents.length === 0) {
cursor.value = null
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = documents.length
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
const moveto = increment(index, d)
cursor.value = documents[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = documents[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else documentStore.selected.add(key)
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
@@ -214,29 +199,36 @@ const focusBreadcrumb = () => {
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
if (cursor.value) {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key
if (store.cursor) {
const a = document.querySelector(
`#file-${cursor.value.key} .name a`
`#file-${store.cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
watchEffect(() => {
if (!props.documents.length && cursor.value) {
cursor.value = null
if (!props.documents.length && store.cursor) {
store.cursor = ''
focusBreadcrumb()
}
})
// Update human-readable x seconds ago messages from mtimes
let nowkey = ref(0)
let modifiedTimer: any = null
const updateModified = () => {
for (const doc of props.documents) doc.modified = formatUnixDate(doc.mtime)
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
})
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Document, name: string) => {
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
@@ -253,34 +245,25 @@ const mkdir = (doc: Document, name: string) => {
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`)
router.push(doc.urlrouter)
}
}
})
doc.name = name // We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
}
// Column sort
const toggleSort = (name: string) => {
sort.value = sort.value === name ? '' : name
}
const sort = ref<string>('')
const sortCompare = {
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
modified: (a: Document, b: Document) => b.mtime - a.mtime,
size: (a: Document, b: Document) => b.size - a.size
}
const sorted = (documents: Document[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents]
if (cmp) sorted.sort(cmp)
return sorted
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
const docloc = docs[i]!.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: Document) => documentStore.selected.has(doc.key)) &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
@@ -291,16 +274,16 @@ const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Document) => documentStore.selected.has(doc.key))
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
documentStore.selected.add(doc.key)
store.selected.add(doc.key)
} else {
documentStore.selected.delete(doc.key)
store.selected.delete(doc.key)
}
}
}
@@ -308,9 +291,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc
console.log('Context menu', ev, doc)
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
const items = [
{ label: '📥 Download', onClick: () => downloadFile(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>
@@ -328,29 +398,36 @@ tbody tr {
position: relative;
z-index: auto;
}
table thead input[type='checkbox'] {
table thead .selection input[type='checkbox'] {
position: inherit;
width: 1em;
height: 1em;
padding: 0.5rem 0.5em;
width: 1rem;
height: 1rem;
padding: 0;
margin: auto;
}
table tbody input[type='checkbox'] {
table tbody .selection input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 2rem;
width: 3rem;
text-align: center;
text-overflow: clip;
padding: 0;
}
table .selection input {
margin: auto;
}
table .modified {
width: 9em;
width: 10rem;
text-overflow: clip;
}
table .size {
width: 5em;
width: 7rem;
text-overflow: clip;
}
table .menu {
width: 1rem;
width: 2rem;
}
tbody td {
font-size: 1.2rem;
@@ -385,7 +462,7 @@ table td {
}
}
thead tr {
font-size: var(--header-font-size);
font-size: 0.8rem;
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
@@ -399,16 +476,15 @@ tbody tr.cursor {
.sortcolumn:hover {
cursor: pointer;
}
.sortcolumn:hover::after {
color: var(--accent-color);
}
.sortcolumn {
padding-right: 1.5rem;
}
.sortcolumn::after {
font-size: 1rem;
content: '▸';
color: #888;
margin-left: 0.5em;
margin-left: 0.5rem;
margin-top: -.2rem;
position: absolute;
transition: all var(--transition-time) linear;
}
@@ -458,3 +534,4 @@ tbody .selection input {
color: #888;
}
</style>
@/stores/main
+26 -9
View File
@@ -1,22 +1,39 @@
<template>
<td class="modified right">
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time>
<time
:datetime=datetime
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>{{ modified }}</time>
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</td>
</template>
<script setup lang="ts">
import type { Document } from '@/repositories/Document'
import { computed } from 'vue'
import { Doc } from '@/repositories/Document'
import { formatUnixDate } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
doc: Doc
now: number
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
// Reference props.now to trigger reactivity when time updates
const modified = computed(() => {
props.now // trigger reactivity
return formatUnixDate(props.doc.mtime)
})
const datetime = computed(() =>
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
)
const tooltip = computed(() =>
datetime.value.replace('T', '\n').replace('Z', ' UTC')
const tooltipText = computed(() =>
datetime.value.replace('T', ' ').replace('Z', ' UTC')
)
const props = defineProps<{
doc: Document
}>()
</script>
+9 -3
View File
@@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import type { Document } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue'
const input = ref<HTMLInputElement | null>(null)
@@ -28,8 +28,8 @@ onMounted(() => {
})
const props = defineProps<{
doc: Document
rename: (doc: Document, newName: string) => void
doc: Doc
rename: (doc: Doc, newName: string) => void
exit: () => void
}>()
@@ -56,4 +56,10 @@ input#FileRenameInput {
outline: none;
font: inherit;
}
.gallery input#FileRenameInput {
padding: .75em;
font-weight: 600;
width: auto;
}
</style>
+3 -3
View File
@@ -3,16 +3,16 @@
</template>
<script setup lang="ts">
import type { Document } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { computed } from 'vue'
const sizeClass = computed(() => {
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
return +unit ? "bytes" : unit
})
const props = defineProps<{
doc: Document
doc: Doc
}>()
</script>
+346
View File
@@ -0,0 +1,346 @@
<template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
const props = defineProps<{
path: Array<string>
documents: Doc[]
}>()
const store = useMainStore()
const router = useRouter()
// File rename
const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null }
const rename = (doc: Doc, newName: string) => {
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
}
const gallery = ref<HTMLElement>()
const columnCount = ref(1)
const updateColumns = () => {
if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
}
const columns = computed(() => columnCount.value)
defineExpose({
newFolder() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
})
store.cursor = editing.value.key
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(key)
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, 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) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === "ArrowLeft") return
if (ev && index === N - 1 && ev.key === "ArrowRight") return
// Calculate new position
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
else {
moveto = increment(index, d)
// Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
}
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
if (moveto === N) focusBreadcrumb()
}
})
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
store.cursor = ''
focusBreadcrumb()
}
})
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
updateColumns()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
const mkdir = (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)
}
}
})
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
const docloc = docs[i]!.loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
}
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}
}
})
const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
const items = [
{ label: '📥 Download', onClick: () => downloadFile(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>
<style scoped>
.gallery {
padding: 1em;
width: 100%;
display: grid;
gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: end;
}
.folder-indicator {
grid-column: 1 / -1;
}
.folder-start {
grid-column-start: 1;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@contextmenu.stop
@focus.stop="store.cursor = doc.key"
@click=onclick
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>
<figure>
<slot></slot>
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
<div class="titlespacer"></div>
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
<template v-if="editing">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
</template>
<template v-else>
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
<span>{{ doc.name }}</span>
<div class=namespacer></div>
</template>
</figcaption>
</figure>
<CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
</CursorTooltip>
</a>
</template>
<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 CursorTooltip from './CursorTooltip.vue'
const store = useMainStore()
type EditingProp = {
rename: (name: string) => void;
exit: () => void;
}
const props = defineProps<{
doc: Doc,
editing?: EditingProp,
}>()
const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = computed(() => props.doc.key)
const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault()
store.cursor = props.doc.key
}
</script>
<style scoped>
.tooltip-name {
font-weight: 600;
text-align: center;
}
.tooltip-details {
text-align: center;
}
figure {
max-height: 15em;
position: relative;
border-radius: .5em;
overflow: hidden;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
figure > article {
flex: 0 0 auto;
}
figure :deep(.video-container) {
height: 15em;
}
.titlespacer {
flex-shrink: 100000;
width: 100%;
height: 2em;
}
figcaption {
position: absolute;
overflow: hidden;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
figcaption input[type='checkbox'] {
width: 1.5em;
height: 1.5em;
margin: .25em 0 .25em .25em;
opacity: 0;
flex-shrink: 0;
transition: opacity var(--transition-time) ease-in-out;
}
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
opacity: 1;
}
figcaption span {
cursor: default;
padding: .5em;
color: #fff;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.cursor figcaption span {
color: var(--accent-color);
}
figcaption .namespacer {
flex-shrink: 100000;
height: 2em;
width: 2em;
}
</style>
+257 -73
View File
@@ -1,81 +1,215 @@
<template>
<nav class="headermain">
<div class="buttons">
<template v-if="documentStore.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => documentStore.fileExplorer!.newFolder()"
<nav class="headermain buttons">
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
tooltip="New folder"
@click="() => { store.fileExplorer!.newFolder() }"
/>
<div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group">
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
@keydown.escape="clearSearch"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
:value="query"
@blur="ev => { if (!query) closeSearch(ev) }"
@input="updateSearch"
placeholder="Search words"
class="margin-input"
@keyup.escape="closeSearch"
/>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="cog" @click="settingsMenu" />
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
</div>
<div class="spacer smallgap"></div>
<div v-if="store.space.disk" class="disk-space"
@mouseenter="diskTooltip?.startHover"
@mousemove="diskTooltip?.updatePosition"
@mouseleave="diskTooltip?.endHover"
>
<svg viewBox="0 0 32 32" class="pie-mini">
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
<circle r="16" cx="16" cy="16" fill="#c8e" />
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
<!-- Subtle inner circle for depth -->
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
</svg>
<CursorTooltip ref="diskTooltip" text="Disk space">
<div class="disk-tooltip">
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
<!-- Pie chart centered at 40,40 -->
<circle r="32" cx="40" cy="40" fill="#c8e" />
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
<!-- Labels on the right -->
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
</svg>
</div>
</CursorTooltip>
</div>
<SvgButton name="cog" @click="settingsMenu" />
</nav>
</template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick, watchEffect } from 'vue'
import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect, computed } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
import router from '@/router';
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const store = useMainStore()
const ssoStore = useSsoAuthStore()
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const closeSearch = (ev: Event) => {
if (!showSearchInput.value) return // Already closing
showSearchInput.value = false
const CIRC = 50.27 // 2π×8
// Storage segment (starts at top, -90°)
const pieStorage = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.storage / s.disk
return `${pct * CIRC} ${CIRC}`
})
// Free segment (starts after storage, goes clockwise to bottom area)
const pieFree = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.free / s.disk
return `${pct * CIRC} ${CIRC}`
})
const pieFreeOffset = computed(() => {
const s = store.space
if (!s.disk) return 0
// Start after storage segment
const storagePct = s.storage / s.disk
return -storagePct * CIRC
})
// Free space color: green when plenty, yellow when moderate, red when low
const freeColor = computed(() => {
const s = store.space
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' // Green: > 25% free
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
return '#d44' // Red: < 10% free
})
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
const CIRC_LG = 100.53
const pieStorageLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeOffsetLg = computed(() => {
const s = store.space
if (!s.disk) return 0
return -(s.storage / s.disk) * CIRC_LG
})
const props = defineProps<{
path: Array<string>
query: string
}>()
const clearSearch = (ev: Event) => {
const input = search.value
if (input) {
input.value = ''
updateSearch(ev)
}
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus()
updateSearch(ev)
}
const focusSearch = () => {
search.value?.focus()
}
// Track pending route update
let pendingRouteUpdate: number | null = null
const updateSearch = (ev: Event) => {
const q = (ev.target as HTMLInputElement).value
let p = props.path.join('/')
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/')
console.log("Update search", url)
if (!props.query && q) router.push(url)
else router.replace(url)
}
const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch(ev)
nextTick(() => {
const input = search.value
if (input) input.focus()
const loc = props.path.join('/')
// Start search immediately via store (worker handles it async)
store.search(q, loc)
// Cancel any pending route update
if (pendingRouteUpdate !== null) {
cancelAnimationFrame(pendingRouteUpdate)
}
// 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)
})
}
watchEffect(() => {
if (props.query) showSearchInput.value = true
})
const toggleSearchInput = () => {
search.value?.focus()
}
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
if (documentStore.user.isLoggedIn) {
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
} else {
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
// For external auth, show user name as link to /auth/
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' }
})
}
// Only show password change for non-SSO users
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
}
if (store.user.privileged) {
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) {
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (store.server.public) {
// Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/restricted#theme=light')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
}})
}
ContextMenu.showContextMenu({
// @ts-ignore
@@ -83,36 +217,86 @@ const settingsMenu = (e: Event) => {
items,
})
}
const props = defineProps<{
path: Array<string>
query: string
}>()
defineExpose({
toggleSearchInput,
closeSearch,
clearSearch,
})
</script>
<style scoped>
.buttons {
flex: 1000 0 auto;
padding: 0;
display: flex;
align-items: center;
height: 3.5em;
z-index: 10;
}
.buttons > * {
flex-shrink: 1;
.search-group {
position: relative;
display: flex;
align-items: center;
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: 5.5em;
max-width: 20em;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;
.search-group:focus-within {
background: rgba(255, 255, 255, 0.2);
}
.search-group:focus-within .search-hint {
opacity: 0;
pointer-events: none;
}
.search-group :deep(.action-button) {
width: 2.2em;
height: 2.2em;
flex-shrink: 0;
}
.search-group input[type='search'] {
background: transparent;
color: var(--header-color);
border: none;
outline: none;
font-size: 1.5em;
max-width: 30vw;
padding: 0.2em 0.5em 0.2em 0;
font-size: var(--header-font-size);
flex: 1 1 3em;
min-width: 3em;
}
.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;
}
.disk-space {
display: flex;
align-items: center;
cursor: default;
}
.pie-mini {
width: 1.4em;
height: 1.4em;
}
.disk-tooltip {
line-height: 1.5;
}
.pie-tooltip {
display: block;
}
.pie-tooltip .pie-label {
fill: #fff;
font-size: 9px;
}
</style>
+48 -111
View File
@@ -1,49 +1,65 @@
<template>
<template v-if="documentStore.selected.size">
<div class="smallgap"></div>
<p class="select-text">{{ documentStore.selected.size }} selected </p>
<SvgButton name="download" data-tooltip="Download" @click="download" />
<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="documentStore.selected.clear()"></button>
</template>
<div class="selection-bar" v-if="store.selected.size">
<p class="select-text">{{ store.selected.size }} selected</p>
<DownloadButton />
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
<button
class="action-button unselect"
@click="store.selected.clear()"
@mouseenter="unselectTooltip?.startHover"
@mousemove="unselectTooltip?.updatePosition"
@mouseleave="unselectTooltip?.endHover"
><CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
</div>
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const documentStore = useDocumentStore()
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles
const op = (opName: string, dst?: string) => {
const sel = store.selectedFiles
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
const msg = {
op,
sel: sel.keys.map(key => {
const doc = sel.docs[key]
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
op: opName,
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
documentStore.error = res.error.message
store.error = res.error.message
// Restore hidden items on error
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
documentStore.selected.clear()
store.selected.clear()
return
} else console.log('Unknown control response', msg, res)
}
@@ -53,102 +69,23 @@ const op = (op: string, dst?: string) => {
}
}
const linkdl = (href: string) => {
const a = document.createElement('a')
a.href = href
a.download = ''
a.click()
}
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = ''
let h = handle
console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories
if (hdir && !rel.startsWith(hdir + '/')) {
hdir = ''
h = handle
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) {
console.error('Failed to create directory', hdir, error)
return
}
console.log('Created', hdir)
}
if (doc.dir) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC')
// Download file
let fileHandle
try {
fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error)
return
}
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
if (!res.ok)
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
if (res.body) await res.body.pipeTo(writable)
else {
// Zero-sized files don't have a body, so we need to create an empty file
await writable.truncate(0)
await writable.close()
}
console.log('Saved', hdir + name)
}
}
const download = async () => {
const sel = documentStore.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
documentStore.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
documentStore.selected.clear()
return linkdl(`/files/${files[0][1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
filesystemdl(sel, handle).then(() => {
documentStore.selected.clear()
})
return
} catch (e) {
console.error('Download to folder aborted', e)
}
}
// Otherwise, zip and download
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
documentStore.selected.clear()
}
</script>
<style>
.selection-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.3em 0.5em;
background: transparent;
color: var(--header-color);
}
.select-text {
color: var(--accent-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
padding-right: 0.5em;
}
</style>
-101
View File
@@ -1,101 +0,0 @@
<template>
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
<input
id="username"
name="username"
autocomplete="username"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.username"
/>
<label for="password">Password:</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.password"
/>
</div>
<h3 class="error-text">
{{ loginForm.error || '\u00A0' }}
</h3>
<div class="dialog-buttons">
<div class="spacer"></div>
<input id="submit" type="submit" value="Login" class="button-login" />
</div>
</form>
</ModalDialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useDocumentStore } from '@/stores/documents'
const confirmLoading = ref<boolean>(false)
const store = useDocumentStore()
const loginForm = reactive({
username: '',
password: '',
error: ''
})
const login = async () => {
try {
loginForm.error = ''
confirmLoading.value = true
const msg = await loginUser(loginForm.username, loginForm.password)
store.login(msg.data.username, !!msg.data.privileged)
} catch (error) {
const httpError = error as ISimpleError
loginForm.error = httpError.message || '🛑 Unknown error'
} finally {
confirmLoading.value = false
}
}
</script>
<style scoped>
.login-container {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
<img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span>
<div v-else-if=video() class="video-container">
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<div class="play-overlay"><PlayIcon /></div>
</div>
<div v-else-if=audio() class="audio icon">
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
</div>
<span v-else-if=archive() class="archive icon"></span>
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template>
<script setup lang=ts>
import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
import { Play as PlayIcon } from '@/assets/svg'
const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value)
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
const props = defineProps<{
doc: Doc
quality: string
}>()
const onplay = () => {
if (!media.value) return
media.value.controls = true
media.value.setAttribute('data-playing', '')
}
const onpaused = () => {
if (!media.value) return
media.value.controls = false
media.value.removeAttribute('data-playing')
}
let fscurrent: HTMLVideoElement | null = null
const next = () => {
if (!media.value) return
media.value.load() // Restore poster
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
if (medias.length === 0) return
let el: HTMLAudioElement | HTMLVideoElement | null = null
for (const i in medias) {
if (medias[i] === (fscurrent || media.value)) {
el = medias[+i + 1] ?? medias[0] ?? null
break
}
}
if (!el) return
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
// Fullscreen needs to use the current video element for the next video
// because we are not allowed to fullscreen the next one.
// FIXME: Write our own player to avoid this problem...
const elem = media.value as HTMLVideoElement
const playing = el as HTMLVideoElement
if (elem === playing) {
playing.play() // Only one video, just replay
return
}
if (!fscurrent) {
elem.addEventListener('fullscreenchange', ev => {
if (!fscurrent) return
// Restore the original video element and continue with the one that was playing
fscurrent.currentTime = elem.currentTime
fscurrent.click()
if (!elem.paused) fscurrent.play()
fscurrent = null
elem.src = props.doc.url
elem.poster = poster.value
onpaused()
}, {once: true})
}
fscurrent = playing
elem.src = playing.src
elem.poster = ''
elem.play()
} else {
document.exitFullscreen()
el.click()
}
}
defineExpose({
play() {
if (!media.value) return false
if (media.value.paused) {
media.value.play()
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
if (el === media.value) continue
el.pause()
}
} else {
media.value.pause()
}
return true
},
media,
})
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].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 preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
props.doc.size > 500000 &&
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
)
</script>
<style scoped>
img, embed, .icon, audio, video {
font-size: 8em;
overflow: hidden;
min-width: 50%;
max-width: 100%;
max-height: 100%;
border-radius: calc(.5em / 8);
}
.folder::before {
content: '📁';
}
.folder:hover::before, .cursor .folder::before {
content: '📂';
}
.archive::before {
content: '📦';
}
.file::before {
content: '📄';
}
.ext-img::before {
content: '💿';
}
.ext-exe::before, .ext-msi::before, .ext-dmg::before, .ext-pkg::before {
content: '⚙️';
}
.ext-torrent::before {
content: '🏴‍☠️';
}
.audio audio {
opacity: 0;
transition: opacity var(--transition-time) ease-in-out;
}
.audio:hover audio {
opacity: 1;
}
.audio.icon::before {
width: 100%;
content: '🔈';
}
.audio.icon:has(audio[data-playing])::before {
position: absolute;
content: '🔊';
bottom: 0;
}
.icon {
filter: brightness(0.9);
}
figure.cursor .icon {
filter: brightness(1);
}
img::before {
/* broken image */
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
.video-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 50%;
max-width: 100%;
max-height: 100%;
}
.video-container video {
width: 100%;
height: 100%;
border-radius: calc(.5em / 8);
object-fit: contain;
}
.play-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
width: 4em;
height: 4em;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.play-overlay svg {
width: 2em;
height: 2em;
fill: white;
margin-left: 0.25em; /* Visual centering for play triangle */
}
.video-container:hover .play-overlay {
transform: scale(1.1);
}
video[data-playing] + .play-overlay {
opacity: 0;
}
</style>
+254 -54
View File
@@ -1,79 +1,279 @@
<template>
<dialog ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button onclick="dialog.close()">OK</button>
</slot>
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
<div class="modal-dialog" :id="props.name" ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div class="modal-content">
<slot>
Dialog with no content
<button @click="close">OK</button>
</slot>
</div>
</div>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const dialog = ref<HTMLDialogElement | null>(null)
const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null)
const store = useMainStore()
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: ''
}
)
const show = () => {
dialog.value!.showModal()
const close = () => {
store.dialog = ''
releaseGlobalBackdrop()
}
defineExpose({ show })
onMounted(() => {
show()
const props = defineProps<{
title: string,
name: typeof store.dialog,
}>()
const show = () => {
store.dialog = props.name
holdGlobalBackdrop()
nextTick(() => {
overlay.value?.focus()
const input = dialog.value?.querySelector('input')
if (input) input.focus()
})
}
defineExpose({ show, close })
watchEffect(() => {
if (overlay.value) {
overlay.value.focus()
const input = dialog.value?.querySelector('input')
if (input) input.focus()
}
})
</script>
<style>
/* Style for the background */
dialog::backdrop {
content: '';
display: block;
/* ===========================================
MODAL DIALOG GLOBAL STYLES
Shared styling for all modal dialogs.
Login page (auth.py) has matching CSS.
=========================================== */
/* Overlay - covers entire viewport */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(0.4em);
z-index: 1000;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
/* No backdrop - paskia handles that */
}
/* Hide the dialog by default */
dialog[open] {
/* Dialog container */
.modal-dialog {
background: #ddd;
color: black;
display: block;
color: #000;
border: none;
font-size: 1.2rem;
border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
box-shadow: 0 0 1rem #0008;
padding: 0;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
font-size: 1rem;
}
input {
font: inherit;
}
dialog[open] > h1 {
background: var(--soft-color);
/* Dialog title bar */
.modal-dialog > h1 {
background: #146;
color: #fff;
font-size: 1.2rem;
margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem;
font-weight: normal;
margin: 0;
padding: 0.5rem 1rem;
position: sticky;
top: 0;
}
dialog[open] > div {
padding: 1em 0;
/* Dialog content area */
.modal-dialog > .modal-content {
padding: 1rem;
}
/* Section headings inside dialog */
.modal-dialog h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.modal-dialog h3:first-child {
margin-top: 0;
}
/* Links */
.modal-dialog a {
color: #146;
}
.modal-dialog a:hover {
color: #f80;
}
/* Form inputs */
.modal-dialog input[type="text"],
.modal-dialog input[type="password"],
.modal-dialog select {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
min-width: 12rem;
}
.modal-dialog input[type="text"]:focus,
.modal-dialog input[type="password"]:focus,
.modal-dialog select:focus {
outline: none;
border-color: #f80;
}
/* Labels */
.modal-dialog label {
font-size: 1rem;
}
/* Buttons */
.modal-dialog button,
.modal-dialog input[type="submit"],
.modal-dialog input[type="reset"],
.modal-dialog .button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
background: #146;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.modal-dialog button:hover,
.modal-dialog input[type="submit"]:hover,
.modal-dialog input[type="reset"]:hover,
.modal-dialog .button:hover {
background: #f80;
}
.modal-dialog button:disabled,
.modal-dialog input[type="submit"]:disabled,
.modal-dialog input[type="reset"]:disabled,
.modal-dialog .button:disabled {
background: #888;
cursor: not-allowed;
}
/* Small button variant */
.modal-dialog .button.small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Danger button variant */
.modal-dialog .button.danger {
background: #c00;
}
.modal-dialog .button.danger:hover:not(:disabled) {
background: #f00;
}
/* Form row layout (label + input side by side) */
.modal-dialog .form-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
margin-bottom: 0.5rem;
}
/* Form grid for multiple label+input pairs */
.modal-dialog .form-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
}
/* Dialog button row (footer) */
.modal-dialog .dialog-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
/* Error text */
.modal-dialog .error-text {
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
margin: 0.5rem 0;
}
/* Success message */
.modal-dialog .success-message {
background: #f80;
color: #000;
padding: 0.5rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
/* Data tables inside dialogs */
.modal-dialog table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 1rem;
}
.modal-dialog th,
.modal-dialog td {
border: 1px solid #888;
padding: 0.5rem;
text-align: left;
}
.modal-dialog th {
background: #146;
color: #fff;
font-weight: normal;
}
.modal-dialog td {
background: #fff;
}
/* Checkbox alignment in tables */
.modal-dialog td input[type="checkbox"] {
margin: 0;
}
/* Paragraph text */
.modal-dialog p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
/* Loading state */
.modal-dialog .loading {
padding: 2rem;
text-align: center;
color: #666;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)" @click.stop
@change="ev => {
if ((ev.target as HTMLInputElement).checked) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}"
>
</template>
<script setup lang=ts>
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document'
const props = defineProps<{
doc: Doc
}>()
const store = useMainStore()
</script>
+91
View File
@@ -0,0 +1,91 @@
<template>
<ModalDialog name=settings title="Settings">
<form>
<template v-if="store.user.isLoggedIn">
<h3>Update your authentication</h3>
<div class="form-grid">
<label for="passwordChange">New password:</label>
<input
ref="passwordChange"
id="passwordChange"
type="password"
autocomplete="new-password"
spellcheck="false"
autocorrect="off"
v-model="form.passwordChange"
/>
<label for="password">Current password:</label>
<input
ref="password"
id="password"
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
v-model="form.password"
/>
</div>
<div class="dialog-buttons">
<input id="close" type="reset" value="Close" class="button" @click=close />
<div class="spacer"></div>
<input id="submit" type="submit" value="Submit" class="button" @click.prevent="submit" />
</div>
</template>
<template v-else>
<p>No settings are available because you have not logged in.</p>
<div class="dialog-buttons">
<div class="spacer"></div>
<input id="close" type="reset" value="Close" class="button" @click=close />
</div>
</template>
</form>
</ModalDialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { changePassword } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const passwordChange = ref()
const password = ref()
const form = reactive({
passwordChange: '',
password: ''
})
const close = () => {
form.passwordChange = ''
form.password = ''
store.dialog = ''
}
const submit = async (ev: Event) => {
ev.preventDefault()
try {
if (form.passwordChange) {
if (!form.password) {
store.showToast('⚠️ Current password is required')
password.value!.focus()
return
}
await changePassword(store.user.username, form.passwordChange, form.password)
}
close()
} catch (error) {
const httpError = error as ISimpleError
store.showToast(httpError.message || '🛑 Unknown error')
} finally {
confirmLoading.value = false
}
}
</script>
<style scoped>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+15 -5
View File
@@ -1,18 +1,28 @@
<template>
<button class="action-button">
<component :is="icon" />
<button
class="action-button"
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>
<component :is="icons[name]" />
<slot></slot>
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</button>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineProps } from 'vue'
import { icons, type IconName } from '@/assets/svg'
import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
name: string
name: IconName
tooltip?: string
}>()
const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`))
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = props.tooltip ?? ''
</script>
<style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div class="transferprogress" v-if="status.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
<div class="statustext">
<span v-if="status.filecount > 1" class="index">
[{{ status.fileidx }}/{{ status.filecount }}]
</span>
<span class="filename">{{ status.filename.split('/').pop() }}
<span v-if="status.filesize > 1e7" class="percent">
{{ (status.filepos / status.filesize * 100).toFixed(0) + '\u202F%' }}
</span>
</span>
<span class="position" v-if="status.total > 1e7">
{{ (status.xfer / 1e6).toFixed(0) + '\u202F/\u202F' + (status.total / 1e6).toFixed(0) + '\u202FMB' }}
</span>
<span class="speed">{{ speeddisp }}</span>
<button class="close" @click="$emit('cancel')"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
defineEmits(['cancel'])
const props = defineProps<{
status: {
total: number
xfer: number
filecount: number
fileidx: number
filesize: number
filepos: number
filename: string
statbytes: number
statdur: number
tlast: number
}
}>()
const percent = computed(() => props.status.xfer / props.status.total * 100)
const speed = computed(() => {
let s = props.status.statbytes / props.status.statdur / 1e3
const tsince = (Date.now() - props.status.tlast) / 1e3
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed"
})
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
</script>
<style scoped>
.transferprogress {
--bar: var(--accent-color);
--nobar: transparent;
display: flex;
flex-direction: column;
justify-content: center;
color: var(--primary-color);
width: 100%;
}
.statustext {
display: flex;
align-items: center;
justify-content: center;
margin: 0 .5em;
padding: 0.5rem 0;
}
span {
color: #ccc;
white-space: nowrap;
text-align: right;
padding: 0 0.5em;
}
.filename {
color: #fff;
flex: 1 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.index { min-width: 3.5em }
.position { min-width: 4em }
.speed { min-width: 4em }
.upload .statustext::before {
font-size: 1.5em;
content: '🔺'
}
.download .statustext::before {
font-size: 1.5em;
content: '🔻'
}
</style>
+81 -108
View File
@@ -1,12 +1,25 @@
<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
</template>
<script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useDocumentStore } from '@/stores/documents'
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils';
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const fileInput = ref()
const folderInput = ref()
const documentStore = useDocumentStore()
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
@@ -25,7 +38,7 @@ function pasteHandler(event: ClipboardEvent) {
const entry = item.webkitGetAsEntry()
if (entry?.isFile) {
const file = item.getAsFile()
infiles.push(file)
if (file) infiles.push(file)
} else if (entry?.isDirectory) {
dirs.push(entry as FileSystemDirectoryEntry)
}
@@ -63,24 +76,53 @@ function uploadHandler(event: Event) {
const uploadFiles = (infiles: File[]) => {
const loc = props.path!.join('/')
let files = []
let folderName = ''
for (const file of infiles) {
const relPath = file.webkitRelativePath || file.name
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
files.push({
file,
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
cloudName: loc + '/' + relPath,
cloudPos: 0,
})
}
uploadCloudFiles(files)
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
}
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) {
documentStore.error = "Won't upload dotfiles"
console.log("Dotfiles omitted", dotfiles)
store.showToast("Won't upload dotfiles")
files = files.filter(f => !f.cloudName.includes('/.'))
}
if (!files.length) return
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, 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, mtime: now, dir: false }))
}
// @ts-ignore
upqueue = [...upqueue, ...files]
statsAdd(files)
@@ -94,7 +136,7 @@ const cancelUploads = () => {
const uprogress_init = {
total: 0,
uploaded: 0,
xfer: 0,
t0: 0,
tlast: 0,
statbytes: 0,
@@ -108,59 +150,50 @@ const uprogress_init = {
filepos: 0,
status: 'idle',
}
const uprogress = reactive({...uprogress_init})
const percent = computed(() => uprogress.uploaded / uprogress.total * 100)
const speed = computed(() => {
let s = uprogress.statbytes / uprogress.statdur / 1e3
const tsince = (Date.now() - uprogress.tlast) / 1e3
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed"
})
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
store.uprogress = {...uprogress_init}
setInterval(() => {
if (Date.now() - uprogress.tlast > 3000) {
if (Date.now() - store.uprogress.tlast > 3000) {
// Reset
uprogress.statbytes = 0
uprogress.statdur = 1
store.uprogress.statbytes = 0
store.uprogress.statdur = 1
} else {
// Running average by decay
uprogress.statbytes *= .9
uprogress.statdur *= .9
store.uprogress.statbytes *= .9
store.uprogress.statdur *= .9
}
}, 100)
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
if (name !== uprogress.filename) return // If stats have been reset
if (name !== store.uprogress.filename) return // If stats have been reset
const now = Date.now()
uprogress.uploaded = uprogress.filestart + end
uprogress.filepos = end
uprogress.statbytes += end - start
uprogress.statdur += now - uprogress.tlast
uprogress.tlast = now
store.uprogress.xfer = store.uprogress.filestart + end
store.uprogress.filepos = end
store.uprogress.statbytes += end - start
store.uprogress.statdur += now - store.uprogress.tlast
store.uprogress.tlast = now
// File finished?
if (end === size) {
uprogress.filestart += size
store.uprogress.filestart += size
statNextFile()
if (++uprogress.fileidx >= uprogress.filecount) statReset()
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
}
}
const statNextFile = () => {
const f = uprogress.files.shift()
const f = store.uprogress.files.shift()
if (!f) return statReset()
uprogress.filepos = 0
uprogress.filesize = f.file.size
uprogress.filename = f.cloudName
store.uprogress.filepos = 0
store.uprogress.filesize = f.file.size
store.uprogress.filename = f.cloudName
}
const statReset = () => {
Object.assign(uprogress, uprogress_init)
uprogress.t0 = Date.now()
uprogress.tlast = uprogress.t0 + 1
Object.assign(store.uprogress, uprogress_init)
store.uprogress.t0 = Date.now()
store.uprogress.tlast = store.uprogress.t0 + 1
}
const statsAdd = (f: CloudFile[]) => {
if (uprogress.files.length === 0) statReset()
uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
uprogress.filecount += f.length
uprogress.files = [...uprogress.files, ...f]
if (store.uprogress.files.length === 0) statReset()
store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
store.uprogress.filecount += f.length
store.uprogress.files = [...store.uprogress.files, ...f]
statNextFile()
}
let upqueue = [] as CloudFile[]
@@ -171,13 +204,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
open(ev: Event) { resolve(ws) },
error(ev: Event) {
console.error('Upload socket error', ev)
documentStore.error = 'Upload socket error'
store.error = 'Upload socket error'
},
message(ev: MessageEvent) {
const res = JSON.parse(ev!.data)
if ('error' in res) {
console.error('Upload socket error', res.error)
documentStore.error = res.error.message
store.error = res.error.message
return
}
if (res.status === 'ack') {
@@ -190,7 +223,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
// @ts-ignore
ws.sendData = async (data: any) => {
// Wait until the WS is ready to send another message
uprogress.status = "uploading"
store.uprogress.status = "uploading"
await new Promise(resolve => {
const t = setInterval(() => {
if (ws.bufferedAmount > 1<<20) return
@@ -198,14 +231,14 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
clearInterval(t)
}, 1)
})
uprogress.status = "processing"
store.uprogress.status = "processing"
ws.send(data)
}
})
const worker = async () => {
const ws = await WSCreate()
while (upqueue.length) {
const f = upqueue[0]
const f = upqueue[0]!
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 }
@@ -219,7 +252,7 @@ const worker = async () => {
if (f.cloudPos === f.file.size) upqueue.shift()
}
if (upqueue.length) startWorker()
uprogress.status = "idle"
store.uprogress.status = "idle"
workerRunning = false
}
let workerRunning: any = false
@@ -242,63 +275,3 @@ onUnmounted(() => {
removeEventListener('drop', uploadHandler)
})
</script>
<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
<div class="uploadprogress" v-if="uprogress.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
<div class="statustext">
<span v-if="uprogress.filecount > 1" class="index">
[{{ uprogress.fileidx }}/{{ uprogress.filecount }}]
</span>
<span class="filename">{{ uprogress.filename.split('/').pop() }}
<span v-if="uprogress.filesize > 1e7" class="percent">
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
</span>
</span>
<span class="position" v-if="uprogress.total > 1e7">
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
</span>
<span class="speed">{{ speeddisp }}</span>
<button class="close" @click="cancelUploads"></button>
</div>
</div>
</template>
<style scoped>
.uploadprogress {
--bar: var(--accent-color);
--nobar: var(--header-background);
display: flex;
flex-direction: column;
color: var(--primary-color);
position: fixed;
left: 0;
bottom: 0;
width: 100vw;
}
.statustext {
display: flex;
padding: 0.5rem 0;
}
span {
color: #ccc;
white-space: nowrap;
text-align: right;
padding: 0 0.5em;
}
.filename {
color: #fff;
flex: 1 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.index { min-width: 3.5em }
.position { min-width: 4em }
.speed { min-width: 4em }
</style>
@@ -0,0 +1,228 @@
<template>
<ModalDialog name=usermgmt title="Admin Settings">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<label for="publicAccess">
<input
type="checkbox"
id="publicAccess"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
Public access (anyone can read and write)
</label>
</div>
<template v-if="store.server.paskia">
<h3>User Management</h3>
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
</template>
<template v-else>
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
</div>
<table>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td>{{ user.username }}</td>
<td>
<input
type="checkbox"
:checked="user.privileged"
@change="toggleAdmin(user, $event)"
:disabled="user.username === store.user.username"
/>
</td>
<td>
<button @click="renameUser(user)" class="button small" title="Rename user"></button>
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button>
</td>
</tr>
</tbody>
</table>
</template>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
interface User {
username: string
privileged: boolean
lastSeen: number
}
const store = useMainStore()
const loading = ref(true)
const users = ref<User[]>([])
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
public: false
})
const close = () => {
store.dialog = ''
success.value = ''
}
const loadUsers = async () => {
try {
loading.value = true
const data = await listUsers()
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to load users')
} finally {
loading.value = false
}
}
const addUser = async () => {
const username = window.prompt('Enter username for new user:')
if (!username || !username.trim()) return
try {
success.value = ''
const result = await createUser(username.trim(), undefined, false)
await loadUsers()
if (result.password) {
success.value = `User ${username.trim()} created. Password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to add user')
}
}
const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement
try {
await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to update user')
target.checked = user.privileged // revert
}
}
const renameUser = async (user: User) => {
const newName = window.prompt('Enter new username:', user.username)
if (!newName || !newName.trim() || newName.trim() === user.username) return
// For rename, we need to create new user and delete old, or have a rename endpoint
// Since no rename endpoint, perhaps delete and create
try {
success.value = ''
const result = await createUser(newName.trim(), undefined, user.privileged)
await deleteUser(user.username)
await loadUsers()
if (result.password) {
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to rename user')
}
}
const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
try {
success.value = ''
const result = await updateUser(user.username, { password: "" })
if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to reset password')
}
}
const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return
try {
await deleteUser(username)
await loadUsers()
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to delete user')
}
}
const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1]!)
if (isButtonClick) {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
success.value = ''
copyButtonText.value = '📋'
}, 3000)
} else {
// Just hide the message when clicking elsewhere
success.value = ''
}
}
}
const updateServerSettings = async () => {
try {
success.value = ''
await updatePublic(serverSettings.public)
// Update store
store.server.public = serverSettings.public
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to update settings')
}
}
onMounted(() => {
serverSettings.public = store.server.public || false
loading.value = false
})
// Load users when dialog opens (only in built-in auth mode)
watch(() => store.dialog, (newVal) => {
if (newVal === 'usermgmt' && !store.server.paskia) {
loadUsers()
}
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal || false
})
</script>
<style scoped>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+65 -14
View File
@@ -1,21 +1,71 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// Type for API error responses
interface ApiError {
error: {
code: number
message: string
}
}
function hasError(msg: unknown): msg is ApiError {
return typeof msg === 'object' && msg !== null && 'error' in msg
}
class ClientClass {
async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
async get(url: string): Promise<any> {
try {
msg = await res.json()
const msg = await apiJson(url, { method: 'GET' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
}
async post(url: string, data?: Record<string, any>): Promise<any> {
try {
const msg = await apiJson(url, {
method: 'POST',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
}
async put(url: string, data?: Record<string, any>): Promise<any> {
try {
const msg = await apiJson(url, {
method: 'PUT',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
}
async delete(url: string): Promise<any> {
try {
const msg = await apiJson(url, { method: 'DELETE' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
}
@@ -32,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
}
}
export { apiFetch }
export default Client
+58 -6
View File
@@ -1,17 +1,69 @@
import { formatSize, formatUnixDate } from "@/utils"
export type FUID = string
export type Document = {
export type DocProps = {
loc: string
name: string
key: FUID
size: number
sizedisp: string
mtime: number
modified: string
haystack: string
dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
}
export class Doc {
public loc: string = ""
public key: FUID = ""
public size: number = 0
public mtime: number = 0
public dir: boolean = false
public ghost: boolean = false
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */
public _name: string = ""
constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props
Object.assign(this, rest)
if (name) this._name = name // Skip validation/haystack for bulk loading
}
get name() { return this._name }
set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name
}
get sizedisp(): string { return formatSize(this.size) }
get modified(): string { return formatUnixDate(this.mtime) }
get url(): string {
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get urlrouter(): string {
return this.url.replace(/^\/#/, '')
}
get img(): boolean {
// Folders cannot be images
if (this.dir) return false
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
}
get previewable(): boolean {
// Folders cannot be previewable
if (this.dir) return false
if (this.img) return true
// Not a comprehensive list, but good enough for now
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
}
get previewurl(): string {
return this.url.replace(/^\/files/, '/preview')
}
get ext(): string {
const dotIndex = this.name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === this.name.length - 1) return ''
return this.name.slice(dotIndex + 1).toLowerCase()
}
}
export type errorEvent = {
error: {
code: number
@@ -36,7 +88,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>
// Helper structure for selections
export interface SelectedItems {
keys: FUID[]
docs: Record<FUID, Document>
recursive: Array<[string, string, Document]>
docs: Record<FUID, Doc>
recursive: Array<[string, string, Doc]>
missing: Set<FUID>
}
+44 -2
View File
@@ -1,6 +1,8 @@
import Client from '@/repositories/Client'
export const url_login = '/login'
export const url_logout = '/logout '
import { useMainStore } from '@/stores/main'
export const url_login = '/auth/login'
export const url_logout = '/auth/api/logout'
export const url_password = '/auth/password-change'
export async function loginUser(username: string, password: string) {
const user = await Client.post(url_login, {
@@ -13,3 +15,43 @@ export async function logoutUser() {
const data = await Client.post(url_logout)
return data
}
export async function changePassword(username: string, passwordChange: string, password: string) {
const data = await Client.post(url_password, {
username,
passwordChange,
password
})
return data
}
export const url_users = '/auth/users'
export async function listUsers() {
const data = await Client.get(url_users)
return data
}
export async function createUser(username: string, password?: string, privileged?: boolean) {
const data = await Client.post(url_users, {
username,
password,
privileged
})
return data
}
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
const data = await Client.put(`${url_users}/${username}`, changes)
return data
}
export async function deleteUser(username: string) {
const data = await Client.delete(`${url_users}/${username}`)
return data
}
export async function updatePublic(isPublic: boolean) {
const data = await Client.put('/api/config/public', { public: isPublic })
return data
}
+87 -22
View File
@@ -1,4 +1,5 @@
import { useDocumentStore } from "@/stores/documents"
import { useMainStore } from "@/stores/main"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
@@ -6,22 +7,33 @@ export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch'
let tree = [] as FileEntry[]
let reconnectDuration = 500
let reconnDelay = 500
let wsWatch = null as WebSocket | null
// Track when we're awaiting authentication to prevent reconnection loops
let awaitingAuth = false
// Clear the local tree cache (called on logout/auth failure)
export const clearTree = () => {
tree = []
}
export const loadSession = () => {
const store = useDocumentStore()
const s = localStorage['cista-files']
if (!s) return false
const store = useMainStore()
try {
tree = JSON.parse(sessionStorage["cista-files"])
tree = JSON.parse(s)
store.updateRoot(tree)
console.log(`Loaded session with ${tree.length} items cached`)
return true
} catch (error) {
console.log("Loading session failed", error)
return false
}
}
const saveSession = () => {
sessionStorage["cista-files"] = JSON.stringify(tree)
localStorage["cista-files"] = JSON.stringify(tree)
}
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
@@ -30,12 +42,50 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
return webSocket
}
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
async function handleWsAuthError(msg: any) {
const iframe = msg.error?.auth?.iframe
if (iframe) {
// Clear sensitive data immediately on auth failure
const store = useMainStore()
store.clearSensitiveData()
clearTree()
// Stop reconnection attempts while showing auth dialog
awaitingAuth = true
store.authInProgress = true
store.error = '' // Clear any connection message
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
try {
await showAuthIframe(iframe)
// Auth succeeded - reconnect
awaitingAuth = false
store.authInProgress = false
watchConnect()
} catch (e) {
awaitingAuth = false
store.authInProgress = false
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
// Show access denied dialog
store.dialog = 'accessdenied'
} else {
console.error('Auth iframe error:', e)
}
}
return true
}
return false
}
export const watchConnect = () => {
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
const store = useDocumentStore()
const store = useMainStore()
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.log(store.error)
@@ -47,9 +97,9 @@ export const watchConnect = () => {
if (store.connected) return
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.user.isOpenLoginModal = true
if (msg.error.code === 401 || msg.error.code === 403) {
// Show paskia auth iframe (works for both password and paskia modes)
handleWsAuthError(msg)
} else {
store.error = msg.error.message
}
@@ -59,11 +109,10 @@ export const watchConnect = () => {
console.log('Connected to backend', msg)
store.server = msg.server
store.connected = true
reconnectDuration = 500
reconnDelay = 500
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
}
})
}
@@ -74,19 +123,34 @@ export const watchDisconnect = () => {
wsWatch = null
}
// Reset auth state and reconnect - call after successful authentication
export const resumeWatching = () => {
awaitingAuth = false
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
watchConnect()
}
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore()
const store = useMainStore()
// Don't reconnect if we're awaiting authentication or auth iframe is showing
if (awaitingAuth || isAuthIframeOpen()) {
console.log('Skipping reconnect - awaiting authentication')
return
}
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
store.error = 'Reconnecting...'
}
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
if (watchTimeout !== null) clearTimeout(watchTimeout)
watchTimeout = setTimeout(watchConnect, reconnectDuration)
reconnDelay = Math.min(5000, reconnDelay + 500)
// The server closes the websocket after errors, so we need to reopen it
watchTimeout = setTimeout(watchConnect, reconnDelay)
}
@@ -100,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
const store = useMainStore()
store.space = msg.space
break
case !!msg.error:
handleError(msg)
@@ -110,7 +175,7 @@ const handleWatchMessage = (event: MessageEvent) => {
}
function handleRootMessage({ root }: { root: FileEntry[] }) {
const store = useDocumentStore()
const store = useMainStore()
console.log('Watch root', root)
store.updateRoot(root)
tree = root
@@ -118,7 +183,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
}
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useDocumentStore()
const store = useMainStore()
const update = updateData.update
console.log('Watch update', update)
if (!tree) return console.error('Watch update before root')
@@ -142,10 +207,10 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
}
function handleError(msg: errorEvent) {
const store = useDocumentStore()
if (msg.error.code === 401) {
store.user.isOpenLoginModal = true
store.user.isLoggedIn = false
const store = useMainStore()
if (msg.error.code === 401 || msg.error.code === 403) {
// Show paskia auth iframe (works for both password and paskia modes)
handleWsAuthError(msg as any)
return
}
}
+33
View File
@@ -0,0 +1,33 @@
// Non-reactive document storage for the full file list
// This avoids Vue reactivity overhead on large arrays
import type { Doc } from '@/repositories/Document'
import { shallowRef, triggerRef } from 'vue'
// The main document list - shallowRef means only the reference is reactive, not the contents
const documents = shallowRef<Doc[]>([])
// Version counter for manual reactivity triggering
let version = 0
export function getDocuments(): Doc[] {
return documents.value
}
export function setDocuments(docs: Doc[]): void {
documents.value = docs
version++
}
export function getVersion(): number {
return version
}
// Trigger reactivity manually (e.g., after modifications)
export function triggerUpdate(): void {
version++
triggerRef(documents)
}
// For computed dependencies that need to react to document changes
export const documentRef = documents
-131
View File
@@ -1,131 +0,0 @@
import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
import { defineStore } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = {
[filename: string]: FileData
}
type User = {
username: string
privileged: boolean
isOpenLoginModal: boolean
isLoggedIn: boolean
}
export const useDocumentStore = defineStore({
id: 'documents',
state: () => ({
document: [] as Document[],
selected: new Set<FUID>(),
fileExplorer: null as any,
error: '' as string,
connected: false,
server: {} as Record<string, any>,
user: {
username: '',
privileged: false,
isLoggedIn: false,
isOpenLoginModal: false
} as User
}),
actions: {
updateRoot(root: FileEntry[]) {
const docs = []
let loc = [] as string[]
for (const [level, name, key, mtime, size, isfile] of root) {
loc = loc.slice(0, level - 1)
docs.push({
name,
loc: level ? loc.join('/') : '/',
key,
size,
sizedisp: formatSize(size),
mtime,
modified: formatUnixDate(mtime),
haystack: haystackFormat(name),
dir: !isfile,
})
loc.push(name)
}
this.document = docs as Document[]
},
login(username: string, privileged: boolean) {
this.user.username = username
this.user.privileged = privileged
this.user.isLoggedIn = true
this.user.isOpenLoginModal = false
if (!this.connected) watchConnect()
},
loginDialog() {
this.user.isOpenLoginModal = true
},
async logout() {
console.log("Logout")
await logoutUser()
this.$reset()
history.go() // Reload page
}
},
getters: {
isUserLogged(): boolean {
return this.user.isLoggedIn
},
recentDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.mtime - a.mtime)
return ret
},
largeDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.size - a.size)
return ret
},
selectedFiles(): SelectedItems {
const selected = this.selected
const found = new Set<FUID>()
const ret: SelectedItems = {
missing: new Set(),
docs: {},
keys: [],
recursive: [],
}
for (const doc of this.document) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
ret.docs[doc.key] = doc
}
}
// What did we not select?
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Document) {
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) {
const base = ret.docs[key]
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of this.document) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
add(rel, full, doc)
}
}
}
// Sort by rel (name stored as on download)
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
return ret
}
}
})
+402
View File
@@ -0,0 +1,402 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
import { getDocuments, setDocuments, documentRef } from './documentStore'
// Singleton search worker instance
let searchWorker: Worker | null = null
let searchId = 0
let searchStore: ReturnType<typeof useMainStore> | null = null
let loadingTimer: ReturnType<typeof setTimeout> | null = null
let clearOldResultsTimer: ReturnType<typeof setTimeout> | null = null
let lastResultUpdate = 0
function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new SearchWorker()
// Set up message handler once
searchWorker.onmessage = (e) => {
if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances
const docs = e.data.docs.map((d: any) => new Doc(d))
// Cancel the clear-old-results timer since we have new results
if (clearOldResultsTimer) {
clearTimeout(clearOldResultsTimer)
clearOldResultsTimer = null
}
// Throttle rapid intermediate updates to reduce UI flicker
const now = performance.now()
if (!e.data.done && now - lastResultUpdate < 50) {
return // Skip intermediate update if too recent
}
lastResultUpdate = now
searchStore.searchResults = docs
if (e.data.done) {
// Clear the loading timer and hide spinner
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
searchStore.searchLoading = false
}
}
}
return searchWorker
}
// Ghost expiry time in seconds
const GHOST_TTL = 30
// Periodic cleanup interval
let cleanupInterval: ReturnType<typeof setInterval> | null = null
export const useMainStore = defineStore('main', {
state: () => ({
// 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>([]),
query: '' as string,
searchResults: [] as Doc[],
searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
fileExplorer: null as any,
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses)
toastTimeout: null as ReturnType<typeof setTimeout> | null,
connected: false,
authInProgress: false,
cursor: '' as string,
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
gallery: false,
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
},
user: {
username: '' as string,
privileged: false as boolean,
isLoggedIn: false as boolean,
},
space: {
disk: 0,
free: 0,
usage: 0,
storage: 0,
}
}),
persist: {
pick: ['prefs', 'cursor', 'selected'],
serializer: {
deserialize: (data: string): StateTree => {
const ret = JSON.parse(data)
ret.selected = new Set(ret.selected)
return ret
},
serialize: (tree: StateTree): string => {
tree.selected = Array.from(tree.selected)
return JSON.stringify(tree)
}
},
},
actions: {
updateRoot(root: FileEntry[]) {
const docs = []
let loc = [] as string[]
for (const [level, name, key, mtime, size, isfile] of root) {
loc = loc.slice(0, level - 1)
docs.push(new Doc({
name,
loc: level ? loc.join('/') : '/',
key,
size,
mtime,
dir: !isfile,
}))
loc.push(name)
}
// Store in non-reactive external storage
setDocuments(docs)
// Clear ghosts that now exist in the real list
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name))
// Clear hidden paths that no longer exist (deletion confirmed)
for (const path of this.hiddenPaths.keys()) {
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
}
// Start cleanup timer if not running
this.startCleanupTimer()
// Bump version to trigger reactive updates
this.docVersion++
// Sync documents to search worker
this.syncSearchWorker()
},
/** Add a ghost file/folder for optimistic UI updates */
addGhost(doc: Doc) {
doc.ghost = true
doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL
this.ghosts.push(doc)
},
/** Clear all ghosts (e.g., on navigation or refresh) */
clearGhosts() {
this.ghosts = []
},
/** Hide a document path (optimistic delete) */
hideDoc(path: string) {
this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL)
},
/** Unhide a document path (delete failed, restore visibility) */
unhideDoc(path: string) {
this.hiddenPaths.delete(path)
},
/** Start the periodic cleanup timer */
startCleanupTimer() {
if (cleanupInterval) return
cleanupInterval = setInterval(() => this.cleanupExpired(), 5000)
},
/** Stop the cleanup timer */
stopCleanupTimer() {
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
}
},
/** Remove expired ghosts and hidden paths */
cleanupExpired() {
const now = Math.floor(Date.now() / 1000)
const ghostsBefore = this.ghosts.length
const hiddenBefore = this.hiddenPaths.size
this.ghosts = this.ghosts.filter(g => g.expires > now)
for (const [path, expires] of this.hiddenPaths) {
if (expires <= now) this.hiddenPaths.delete(path)
}
// Stop timer if nothing to clean up
if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) {
this.stopCleanupTimer()
}
},
/** Show a temporary toast message that auto-dismisses */
showToast(message: string, duration = 3000) {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout)
this.toastTimeout = null
}
this.toast = message
this.toastTimeout = setTimeout(() => {
this.toast = ''
this.toastTimeout = null
}, duration)
},
/** Clear the current toast immediately */
clearToast() {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout)
this.toastTimeout = null
}
this.toast = ''
},
syncSearchWorker() {
const worker = getSearchWorker()
// Send plain data to worker (no class instances)
const docs = getDocuments()
const docData = docs.map(doc => ({
loc: doc.loc,
name: doc.name,
key: doc.key,
size: doc.size,
mtime: doc.mtime,
dir: doc.dir,
}))
worker.postMessage({ type: 'update', documents: docData })
},
search(query: string, loc: string) {
const worker = getSearchWorker()
const id = ++searchId
searchStore = this // Store reference for worker callback
// Update query immediately so watchers know we're handling this
this.query = query
// Cancel pending timers
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
if (clearOldResultsTimer) {
clearTimeout(clearOldResultsTimer)
clearOldResultsTimer = null
}
if (!query) {
// Clear results only when search is closed
this.searchResults = []
this.searchLoading = false
return
}
// Keep old results briefly to avoid flicker on fast cached searches
// But clear them after 50ms if no new results have arrived
clearOldResultsTimer = setTimeout(() => {
if (searchId === id) {
this.searchResults = []
}
clearOldResultsTimer = null
}, 50)
// Delay showing loading indicator to avoid flicker on fast searches
loadingTimer = setTimeout(() => {
if (searchId === id) { // Still the current search
this.searchLoading = true
}
loadingTimer = null
}, 100)
worker.postMessage({ type: 'search', query, loc, id })
},
login(username: string, privileged: boolean) {
this.user.username = username
this.user.privileged = privileged
this.user.isLoggedIn = true
this.dialog = ''
if (!this.connected) resumeWatching()
},
clearSensitiveData() {
// Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files')
setDocuments([])
this.ghosts = []
this.hiddenPaths.clear()
this.stopCleanupTimer()
this.docVersion++
this.selected.clear()
this.user.username = ''
this.user.privileged = false
this.user.isLoggedIn = false
this.connected = false
this.dialog = ''
this.cursor = ''
},
async logout() {
console.log("Logout")
try {
const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
this.error = data.message || data.detail || 'Logout failed'
return
}
} catch (e) {
this.error = 'Logout failed'
return
}
this.clearSensitiveData()
resumeWatching()
},
toggleSort(name: SortOrder) {
const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
const newOrder = current === name ? '' : name
if (this.query) this.prefs.sortFiltered = newOrder
else this.prefs.sortListing = newOrder
this.showSortToast(newOrder)
},
sort(name: SortOrder | '') {
if (this.query) this.prefs.sortFiltered = 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() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
},
cancelDownloads() {
location.reload() // FIXME
},
cancelUploads() {
location.reload() // FIXME
},
},
getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
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 {
// Access docVersion to make this reactive
void this.docVersion
const docs = getDocuments()
const selected = this.selected
const found = new Set<FUID>()
const ret: SelectedItems = {
missing: new Set(),
docs: {},
keys: [],
recursive: [],
}
for (const doc of docs) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
ret.docs[doc.key] = doc
}
}
// What did we not select?
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// 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) {
const base = ret.docs[key]!
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of docs) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
add(rel, full, doc)
}
}
}
// Sort by rel (name stored as on download)
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
return ret
}
}
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useMainStore } from './main'
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => {
const isExternalAuth = computed(() => {
const mainStore = useMainStore()
return mainStore.server?.paskia === true
})
function clearDataOnUnauth() {
const mainStore = useMainStore()
mainStore.clearSensitiveData()
clearTree()
}
return { isExternalAuth, clearDataOnUnauth }
})
+62
View File
@@ -0,0 +1,62 @@
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils'
export const ordering = {
name: (a: Doc, b: Doc) => collator.compare(a.name, b.name),
modified: (a: Doc, b: Doc) => b.mtime - a.mtime,
size: (a: Doc, b: Doc) => b.size - a.size
}
export type SortOrder = keyof typeof ordering | ''
export const sorted = (documents: Doc[], order: SortOrder) => {
if (!order) return documents
const sorted = [...documents]
sorted.sort(ordering[order])
return sorted
}
/**
* Sort documents while keeping files grouped by their folder.
* - name: folders sorted by folder path, items within by name
* - modified: folders sorted by newest item within results, items within by mtime
* - size: folders sorted by largest file within results, items within by size
*/
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
if (!order) return documents
const compare = ordering[order]
// Group documents by their folder location
const byFolder = new Map<string, Doc[]>()
for (const doc of documents) {
const folder = doc.loc
if (!byFolder.has(folder)) byFolder.set(folder, [])
byFolder.get(folder)!.push(doc)
}
// Sort items within each folder
for (const docs of byFolder.values()) {
docs.sort(compare)
}
// Find the "best" item in each folder (first after sorting = best according to criteria)
const folderBest = new Map<string, Doc>()
for (const [folder, docs] of byFolder) {
folderBest.set(folder, docs[0]!)
}
// Sort folders: by path for name sort, by best item for modified/size
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
if (order === 'name') {
return collator.compare(a, b)
}
return compare(folderBest.get(a)!, folderBest.get(b)!)
})
// Flatten back into a single array with folder grouping preserved
const result: Doc[] = []
for (const folder of sortedFolders) {
result.push(...byFolder.get(folder)!)
}
return result
}
+52
View File
@@ -0,0 +1,52 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
export const exists = (path: string[]) => {
const store = useMainStore()
// Access docVersion to make this reactive
void store.docVersion
const p = path.join('/')
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
}
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
export const stripExt = (name: string): string => {
// Common compound extensions
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
const lower = name.toLowerCase()
for (const ext of compoundExts) {
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
}
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
const lastDot = name.lastIndexOf('.')
if (lastDot > 0) {
const ext = name.slice(lastDot + 1)
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
return name.slice(0, lastDot)
}
}
return name
}
/** Generate a sensible zip filename for a selection of items */
export const zipName = (items: { name: string; loc: string }[]): string => {
const names = items.map(d => d.name)
if (names.length === 1) {
// Single item - use its name
return stripExt(names[0]!)
}
// Check if all items share the same direct parent folder
const locs = items.map(d => d.loc)
const sameLoc = locs.every(loc => loc === locs[0])
if (sameLoc && locs[0]) {
// All items in same folder - use folder name
return locs[0].split('/').pop()!
}
if (names.length <= 3) {
// Few items from different folders - join basenames with dot
return names.map(stripExt).join('.')
}
// Many items from different folders - first basename + indicator
return `${stripExt(names[0]!)}.etc`
}
+9 -9
View File
@@ -50,12 +50,11 @@ export function formatUnixDate(t: number) {
}
export function getFileExtension(filename: string) {
const parts = filename.split('.')
if (parts.length > 1) {
return parts[parts.length - 1]
} else {
return '' // No hay extensión
const dotIndex = filename.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === filename.length - 1) {
return '' // No extension
}
return filename.slice(dotIndex + 1)
}
interface FileTypes {
[key: string]: string[]
@@ -68,9 +67,10 @@ const filetypes: FileTypes = {
}
export function getFileType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase()
if (!ext || ext.length === name.length) return 'unknown'
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
const ext = name.slice(dotIndex + 1).toLowerCase()
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
}
// Prebuilt for fast & consistent sorting
@@ -86,7 +86,7 @@ export function haystackFormat(str: string) {
// Preformat search string for faster search
export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return {based, words: based.split(/\W+/)}
return {based, words: based.split(/\s+/)}
}
// Test if haystack includes needle
+89 -34
View File
@@ -1,58 +1,113 @@
<template>
<FileExplorer
<Gallery
v-if="store.prefs.gallery"
ref="fileExplorer"
:key="Router.currentRoute.value.path"
:key="`gallery-${folderPath}`"
:path="props.path"
:documents="documents"
v-if="props.path"
/>
<FileExplorer
v-else
ref="fileExplorer"
:key="`explorer-${folderPath}`"
:path="props.path"
:documents="documents"
/>
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path />
</template>
<script setup lang="ts">
import { watchEffect, ref, computed } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils';
import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
const documentStore = useDocumentStore()
const store = useMainStore()
const fileExplorer = ref()
const props = defineProps<{
path: Array<string>
query: string
}>()
// Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
// Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing)
watch(
() => [props.query, props.path.join('/')] as const,
([query, loc]) => {
if (store.query === query) return // Already searching this query
store.search(query, loc)
},
{ immediate: true }
)
const documents = computed(() => {
const loc = props.path.join('/')
const query = props.query
// List the current location
if (!query) return documentStore.document.filter(doc => doc.loc === loc)
// Find up to 100 newest documents that match the search
const needle = needleFormat(query)
let limit = 100
let docs = []
for (const doc of documentStore.recentDocuments) {
if (localeIncludes(doc.haystack, needle)) {
docs.push(doc)
if (--limit === 0) break
}
// List the current location (no search)
if (!query) {
// Access docVersion to make this reactive to document changes
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)
}
// Organize by folder, by relevance
const locsub = loc + '/'
docs.sort((a, b) => (
// @ts-ignore
(b.loc === loc) - (a.loc === loc) ||
// @ts-ignore
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
collator.compare(a.loc, b.loc) ||
// @ts-ignore
(a.type === 'file') - (b.type === 'file') ||
// @ts-ignore
b.name.includes(query) - a.name.includes(query) ||
collator.compare(a.name, b.name)
))
// Search results from worker (also filter hidden)
const hidden = store.hiddenPaths
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
// Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered
if (order) return sortedGrouped(docs, order)
// Results are already sorted by relevance in the worker
return docs
})
watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value
store.fileExplorer = fileExplorer.value
})
// Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true })
</script>
<style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
.search-loading {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
background: var(--accent-color, #007bff);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
opacity: 0.9;
}
</style>
+225
View File
@@ -0,0 +1,225 @@
// Search worker - runs search in background thread
// Receives document updates and search queries, returns incremental results
interface DocData {
loc: string
name: string
key: string
size: number
mtime: number
dir: boolean
}
interface WorkerDoc extends DocData {
haystack: string
}
interface SearchMessage {
type: 'search'
query: string
loc: string
id: number
}
interface UpdateMessage {
type: 'update'
documents: DocData[]
}
type IncomingMessage = SearchMessage | UpdateMessage
interface ResultMessage {
type: 'results'
docs: DocData[]
id: number
done: boolean
}
// Worker state
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0
// Search result cache - cleared when documents change
interface CacheEntry {
query: string // Normalized query string
results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents
}
const searchCache: CacheEntry[] = []
const MAX_CACHE_SIZE = 10
const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string {
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
}
function normalizeQuery(str: string): string {
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
// Test if document matches search query
function matches(haystack: string, query: string, words: string[]): boolean {
return haystack.includes(query) || words.every(word => haystack.includes(word))
}
// Collator for sorting
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
// Yield control to allow new messages to be processed
const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
// Find best cache entry to filter from
// Returns entry if new query's results are guaranteed to be a subset of cached results
// Only valid if the cached search was complete (scanned all documents)
function findCacheSubset(query: string): CacheEntry | null {
// Look for a cached query that the new query starts with
// e.g., cached "foo" can be used for "foobar" or "foo bar"
// The longer the prefix, the better (fewer items to filter)
// IMPORTANT: Only use complete cache entries - incomplete ones may have
// missed results that would match the more specific query
let best: CacheEntry | null = null
for (const entry of searchCache) {
if (entry.complete && query.startsWith(entry.query)) {
if (!best || entry.query.length > best.query.length) {
best = entry
}
}
}
return best
}
// Add result to cache
function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
// Remove existing entry for same query if any
const idx = searchCache.findIndex(e => e.query === query)
if (idx !== -1) searchCache.splice(idx, 1)
// Add to front (most recent)
searchCache.unshift({ query, results, complete })
// Trim cache
if (searchCache.length > MAX_CACHE_SIZE) searchCache.pop()
}
// Clear cache (called when documents change)
function clearCache() {
searchCache.length = 0
}
// Perform search with incremental results
async function performSearch(rawQuery: string, loc: string, searchId: number) {
const query = normalizeQuery(rawQuery)
const words = query.split(/\s+/)
const results: WorkerDoc[] = []
let lastResultCount = 0
// Check cache for exact match
const exactMatch = searchCache.find(e => e.query === query)
if (exactMatch) {
if (currentSearchId === searchId) {
postResults(exactMatch.results, rawQuery, loc, searchId, true)
}
return
}
// Check if we can filter from a cached superset
const cacheEntry = findCacheSubset(query)
if (cacheEntry) {
// Fast path: filter from cached results (only used for complete cache entries)
for (const doc of cacheEntry.results) {
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Cache entry was complete, so filtered results are also complete
addToCache(query, results, true)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
return
}
// Slow path: scan all documents
const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
if (currentSearchId !== searchId) return // Superseded
// Process batch
const end = Math.min(i + batchSize, recentDocuments.length)
for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
const doc = recentDocuments[j]!
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Post incremental results if we found new matches
if (results.length > lastResultCount && currentSearchId === searchId) {
lastResultCount = results.length
postResults(results, rawQuery, loc, searchId, false)
}
// Yield control between batches
if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
await yieldControl()
}
}
// Cache and post final results
addToCache(query, results, results.length < RESULT_LIMIT)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
}
// Post results to main thread
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
const sorted = sortResults(docs, query, loc)
postMessage({
type: 'results',
docs: sorted.map(({ haystack, ...rest }) => rest),
id,
done
} as ResultMessage)
}
// Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/'
return [...docs].sort((a, b) => (
// Current folder first
Number(b.loc === loc) - Number(a.loc === loc) ||
// Then subfolders
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
// Then by location
collator.compare(a.loc, b.loc) ||
// Folders before files
Number(b.dir) - Number(a.dir) ||
// Exact name match first
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
// Finally by name
collator.compare(a.name, b.name)
))
}
// Handle incoming messages
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
const msg = e.data
if (msg.type === 'update') {
// Update document list with haystacks, sorted by mtime descending
recentDocuments = msg.documents
.map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
.sort((a, b) => b.mtime - a.mtime)
clearCache()
} else if (msg.type === 'search') {
currentSearchId = msg.id
if (msg.query) {
await performSearch(msg.query, msg.loc, msg.id)
} else {
// Empty query - no results needed
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* FastAPI-Vue Vite Plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
export default function fastapiVue({ paths = ["/api"] } = {}) {
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
proxy[path] = {
target: backendUrl,
changeOrigin: false,
ws: true,
}
}
return {
name: "fastapi-vite",
config: () => ({
server: { proxy },
build: {
outDir: "../cista/frontend-build",
emptyOutDir: true,
},
}),
}
}
+14 -23
View File
@@ -1,27 +1,19 @@
import { fileURLToPath, URL } from 'node:url'
import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// @ts-ignore
import pluginRewriteAll from 'vite-plugin-rewrite-all'
import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
// Development mode:
// npm run dev # Run frontend that proxies to dev_backend
// cista -l :8000 --dev # Run backend
const dev_backend = {
target: "http://localhost:8000",
changeOrigin: false, // Use frontend "host" to match "origin" from browser
ws: true,
}
// https://vitejs.dev/config/
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
export default defineConfig({
plugins: [
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
vue(),
pluginRewriteAll(),
svgLoader(), // import svg files
Components(), // auto import components
],
@@ -38,17 +30,16 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
"/api": dev_backend,
"/files": dev_backend,
"/login": dev_backend,
"/logout": dev_backend,
"/zip": dev_backend,
}
},
build: {
outDir: "../cista/wwwroot",
emptyOutDir: true,
}
rollupOptions: {
output: {
manualChunks: {
// Bundle all SVG icons into a single chunk
icons: [
'/src/assets/svg/index.ts',
],
},
},
},
},
})
+114 -57
View File
@@ -10,94 +10,151 @@ readme = "README.md"
authors = [
{ name = "Vasanko" },
]
maintainers = [
{ name = "Vasanko" },
]
keywords = ["file-server", "web-interface", "dropbox", "storage"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Intended Audience :: End Users/Desktop",
"Intended Audience :: System Administrators",
"License :: Public Domain",
"License :: OSI Approved :: MIT License",
]
requires-python = ">=3.11"
dependencies = [
"argon2-cffi",
"blake3",
"brotli",
"docopt",
"inotify",
"msgspec",
"natsort",
"pathvalidate",
"pyjwt",
"sanic",
"stream-zip",
"tomli_w",
"argon2-cffi>=25.1.0",
"av>=15.0.0",
"blake3>=1.0.5",
"docopt-ng>=0.9.0",
"fastapi-vue>=0.5.2",
"fastapi[standard]>=0.128.0",
"html5tagger>=1.3.0",
"httpx>=0.28.0",
"inotify>=0.2.12",
"msgspec>=0.19.0",
"natsort>=8.4.0",
"numpy>=2.3.2",
"pathvalidate>=3.3.1",
"pillow>=11.3.0",
"pillow-heif>=1.1.0",
"pyjwt>=2.10.1",
"pymupdf>=1.26.3",
"sanic>=25.12.0",
"setproctitle>=1.3.6",
"stream-zip>=0.0.83",
"tomli_w>=1.2.0",
"zstandard>=0.24.0",
]
[project.urls]
Homepage = ""
Homepage = "https://git.zi.fi/Vasanko/cista-storage"
[project.scripts]
cista = "cista.__main__:main"
[project.optional-dependencies]
dev = [
"pytest",
"ruff",
"pytest>=8.4.1",
"ruff>=0.8.0",
"mypy>=1.13.0",
"pre-commit>=4.0.0",
]
test = [
"pytest>=8.4.1",
"pytest-cov>=6.0.0",
"pytest-asyncio>=0.25.0",
]
docs = [
"sphinx>=8.0.0",
"sphinx-rtd-theme>=3.0.0",
]
[tool.hatchling]
# Build frontend
pre_build = "npm run build --prefix cista-front"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build]
artifacts = ["cista/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
targets.sdist.include = [
"/cista",
]
hooks.vcs.version-file = "cista/_version.py"
hooks.vcs.template = """
# This file is automatically generated by hatch build.
__version__ = {version!r}
"""
targets.sdist.include = [
"/cista",
]
only-packages = true
packages = ["cista"]
[tool.pytest.ini_options]
addopts = [
"--import-mode=importlib",
"--verbosity=-1",
"-p no:warnings",
"--verbosity=2",
"--strict-markers",
"--strict-config",
"--cov=cista",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-branch",
]
testpaths = [
"tests",
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
filterwarnings = [
"error",
"ignore::UserWarning",
"ignore::DeprecationWarning",
]
[tool.ruff]
select = ["ALL"]
ignore = [
"A0",
"ARG001",
"ANN",
"B018",
"BLE001",
"C901",
"COM812", # conflicts with ruff format
"D",
"E501",
"EM1",
"FIX002",
"ISC001", # conflicts with ruff format
"PGH003",
"PLR0912",
"PLR2004",
"PLW0603",
"S101",
"SLF001",
"T201",
"TD0",
"TRY",
[tool.ruff.lint]
isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
per-file-ignores."scripts/*" = ["T20"]
[dependency-groups]
dev = [
"pytest>=8.4.1",
"ruff>=0.8.0",
"mypy>=1.13.0",
"pre-commit>=4.0.0",
"httpx>=0.28.1",
]
test = [
"pytest>=8.4.1",
"pytest-cov>=6.0.0",
"pytest-asyncio>=0.25.0",
]
show-source = true
show-fixes = true
[tool.ruff.isort]
known-first-party = ["cista"]
[tool.coverage.run]
source = ["cista"]
branch = true
omit = [
"*/tests/*",
"*/test_*",
"*/__pycache__/*",
"cista/_version.py",
]
[tool.ruff.per-file-ignores]
"tests/*" = ["S", "ANN", "D", "INP"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]
show_missing = true
skip_covered = false
precision = 2
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [frontend] [--backend backend] [cista_args...]
Options:
frontend Vite frontend endpoint (default: localhost:8989)
--backend Cista backend endpoint (default: from config, or :8999)
Any additional arguments are passed to the cista command.
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
"""
import argparse
import asyncio
import contextlib
import os
import sys
from pathlib import Path
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
from cista import config
from cista.serve import parse_listen
DEFAULT_BACKEND_PORT = 8999
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str, list[str]]:
"""Parse backend listen address and build cista dev command.
Returns (url, cmd).
"""
config.load_config()
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
url, opts = parse_listen(listen)
port = opts.get("port", DEFAULT_BACKEND_PORT)
host = opts.get("host", "localhost") or "localhost"
cmd = ["cista", "--dev", "-l", listen] + extra_args
return f"http://{host}:{port}", cmd
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
if not (front / "package.json").exists():
logger.warning("Frontend source not found at %s", front)
raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "")
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
# Tell vite where to proxy API requests
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
async with ProcessGroup() as pg:
install_proc = await pg.spawn(*npm_install, cwd=str(front))
await asyncio.sleep(0.2) # reduce message overlap
await pg.spawn(*sanic_cmd, cwd=str(reporoot))
# Wait for both install and backend to be ready
async with asyncio.TaskGroup() as tg:
tg.create_task(pg.wait(install_proc))
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
await pg.spawn(*vite, cwd=str(front))
def main():
parser = argparse.ArgumentParser(
description="Run Vite and Cista (Sanic) development servers",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=HELP_EPILOG,
)
parser.add_argument(
"frontend",
nargs="?",
metavar="host:port",
help="Vite frontend endpoint (default: localhost:8989)",
)
parser.add_argument(
"--backend",
"-l",
metavar="host:port",
help="Cista backend endpoint (default: from config, or :8999)",
)
args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend, unknown))
HELP_EPILOG = """
scripts/devserver.py # Default ports
scripts/devserver.py 3000 # Vite on localhost:3000
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
Additional arguments are passed to the cista backend command.
JS_RUNTIME environment variable can be used to select the JS runtime
"""
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
"""Hatch build hook for building Vue frontend during package build."""
import sys
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
build("frontend")
+191
View File
@@ -0,0 +1,191 @@
"""Utilities used at build time and in devserver script. No dependencies."""
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level."""
def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.WARNING:
return f"┃ ⚠️ {record.getMessage()}"
return record.getMessage()
_handler = logging.StreamHandler()
_handler.setFormatter(_PrefixFormatter())
logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler)
logger.setLevel(logging.INFO)
def _check_node_version(node_path: str) -> None:
"""Check if Node.js version is >= 20.
Raises RuntimeError if version is too old or cannot be determined.
"""
try:
result = subprocess.run(
[node_path, "--version"], capture_output=True, text=True, check=True
)
version_str = result.stdout.strip()
# Parse version like "v20.10.0" or "v18.17.1"
match = re.match(r"v(\d+)", version_str)
if match:
major_version = int(match.group(1))
if major_version >= 20:
return
raise RuntimeError(
f"Node.js {version_str} found, but v20+ required (install with nvm)"
)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
pass
raise RuntimeError("Could not determine Node.js version")
def find_js_runtime() -> tuple[str, str]:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Raises JSRuntimeError if no suitable runtime is found.
"""
options = ["npm", "deno", "bun"]
node_version_error: RuntimeError | None = None
# Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"):
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
raise RuntimeError(
f"JS_RUNTIME={js_runtime_env}: {option} not found"
)
# Check Node.js version if using npm
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
raise RuntimeError(
f"JS_RUNTIME={js_runtime_env}: node not found"
)
_check_node_version(node_path) # Raises on failure
return tool, option
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
# Auto-detect
for option in options:
if tool := shutil.which(option):
# Check Node.js version if using npm
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
continue
try:
_check_node_version(node_path)
except RuntimeError as e:
node_version_error = e
continue # Try next runtime
return tool, option
# No runtime found - provide helpful error
if node_version_error:
raise node_version_error
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
def find_build_tool():
"""Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists.
Raises RuntimeError if no runtime is found.
"""
install = {
"deno": ("install", "--allow-scripts=npm:vue-demi"),
"npm": ("install",),
"bun": ("--bun", "install"),
}
# Run vite directly for deno to avoid npm-run-all2/run-p issues
build = {
"deno": ("run", "-A", "npm:vite", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
tool, name = find_js_runtime()
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool() -> list[str]:
"""Find JavaScript runtime and construct dev command.
Returns dev_cmd (without vite-specific args).
Raises RuntimeError if no runtime is found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
tool, name = find_js_runtime()
if name == "bun":
logger.warning(
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
)
return [tool, *dev_args[name]]
def find_install_tool() -> list[str]:
"""Find JavaScript runtime and construct install command.
Returns install_cmd.
Raises RuntimeError if no runtime is found.
"""
install_args = {
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
"npm": ("install", "--silent"),
"bun": ("install", "--silent"),
}
tool, name = find_js_runtime()
return [tool, *install_args[name]]
def build(folder: str = "frontend") -> None:
"""Build the frontend in the specified folder.
Raises SystemExit(1) on failure.
"""
logger.info(">>> Building %s", folder)
try:
install_cmd, build_cmd = find_build_tool()
except RuntimeError as e:
logger.warning(e)
raise SystemExit(1)
def run(cmd):
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder)
try:
run(install_cmd)
logger.info("")
run(build_cmd)
except subprocess.CalledProcessError:
raise SystemExit(1)
+157
View File
@@ -0,0 +1,157 @@
"""Utilities meant for devserver script, used only in source repository with dev deps."""
import asyncio
from pathlib import Path
import httpx
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
DEFAULT_VITE_PORT = 8989
DEFAULT_BACKEND_PORT = 8999
class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
def __init__(self):
self._procs: list[asyncio.subprocess.Process] = []
async def spawn(
self, *cmd: str, cwd: str | None = None
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
return proc
async def wait(self, proc: asyncio.subprocess.Process) -> None:
"""Wait for a process to complete, raise SystemExit(1) on failure."""
if await proc.wait() != 0:
logger.warning("Command failed")
raise SystemExit(1)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, *_):
"""Wait for one process to exit, terminate others, then wait for all."""
cleanup_task = asyncio.create_task(self._cleanup())
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError:
# Shield was cancelled but cleanup_task continues - wait for it
await cleanup_task
async def _cleanup(self):
running = [p for p in self._procs if p.returncode is None]
if not running:
return
# Wait for any one process to exit
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
try:
p.terminate()
except ProcessLookupError:
pass
# Wait for all to finish (with overall timeout)
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
try:
await asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
try:
p.kill()
except ProcessLookupError:
pass
await p.wait()
async def ready(url: str, path: str = "") -> None:
"""Wait for the server to be ready by polling an endpoint.
Raises SystemExit(1) if server doesn't start in time.
"""
max_attempts = 50
full_url = f"{url}{path}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(full_url, timeout=1.0)
logger.info("✓ Backend ready!")
return
except httpx.RequestError:
if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time")
raise SystemExit(1)
await asyncio.sleep(0.1)
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
"""Parse frontend endpoint and build commands.
Returns (url, install_cmd, dev_cmd).
Raises SystemExit(1) on invalid config.
"""
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver")
raise SystemExit(1)
port = endpoints[0]["port"]
host = endpoints[0]["host"]
install_cmd = find_install_tool()
dev_cmd = find_dev_tool()
if host != "localhost":
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
if port != 5173:
dev_cmd.append(f"--port={port}")
return f"http://{host}:{port}", install_cmd, dev_cmd
def setup_fastapi(
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build fastapi dev command.
Returns (url, cmd).
Raises SystemExit(1) on invalid config.
"""
endpoints = parse_endpoint(endpoint, default_port)
if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver")
raise SystemExit(1)
host = endpoints[0]["host"]
port = endpoints[0]["port"]
cmd = [
"fastapi",
"dev",
"--entrypoint",
module,
"--host",
host,
"--port",
str(port),
]
return f"http://{host}:{port}", cmd
+9 -55
View File
@@ -1,10 +1,7 @@
from pathlib import PurePosixPath
import msgspec
import pytest
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
from cista.watching import State, format_update
from cista.watching import format_update
def decode(data: str):
@@ -37,6 +34,14 @@ def test_insertions():
assert decode(format_update(old_list, new_list)) == expected
def test_insertion_at_end():
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
new_list = [*old_list, newfile]
expected = [UpdKeep(4), UpdIns([newfile])]
assert decode(format_update(old_list, new_list)) == expected
def test_deletions():
old_list = f(3)
new_list = [old_list[0], old_list[2]]
@@ -83,54 +88,3 @@ def test_longer_lists():
def sortkey(name):
# Define the sorting key for names here
return name.lower()
@pytest.fixture()
def state():
entries = [
FileEntry(0, "", "root", 0, 0, 0),
FileEntry(1, "bar", "bar", 0, 0, 0),
FileEntry(2, "baz", "bar/baz", 0, 0, 0),
FileEntry(1, "foo", "foo", 0, 0, 0),
FileEntry(1, "xxx", "xxx", 0, 0, 0),
FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1),
]
s = State()
s._listing = entries
return s
def test_existing_directory(state):
path = PurePosixPath("bar")
expected_slice = slice(1, 3) # Includes 'bar' and 'baz'
assert state._slice(path) == expected_slice
def test_existing_file(state):
path = PurePosixPath("xxx/yyy")
expected_slice = slice(5, 6) # Only includes 'yyy'
assert state._slice(path) == expected_slice
def test_nonexistent_directory(state):
path = PurePosixPath("zzz")
expected_slice = slice(6, 6) # 'zzz' would be inserted at end
assert state._slice(path) == expected_slice
def test_nonexistent_file(state):
path = (PurePosixPath("bar/mmm"), 1)
expected_slice = slice(3, 3) # A file would be inserted after 'baz' under 'bar'
assert state._slice(path) == expected_slice
def test_root_directory(state):
path = PurePosixPath()
expected_slice = slice(0, 6) # Entire tree
assert state._slice(path) == expected_slice
def test_directory_with_subdirs_and_files(state):
path = PurePosixPath("xxx")
expected_slice = slice(4, 6) # Includes 'xxx' and 'yyy'
assert state._slice(path) == expected_slice