Compare commits

...
90 Commits
Author SHA1 Message Date
LeoVasanko 7b1c6f6772 Fix preview pool permanently wedging after worker stderr pipe fills
Production symptom: previews of all types (pdf/pyvips/onlyoffice) start
hitting the 10s timeout and never recover until server restart, while the
rest of the server stays healthy.

Root cause (reproduced on Python 3.12): workers were spawned with
stderr=PIPE that nothing drained after startup. Once the OS pipe buffer
filled from accumulated worker tracebacks and library warnings, asyncio
flow control stopped the parent reading it and the worker blocked forever
mid-request on a stderr write. The 10s timeout then fired, but
_replace_worker hung forever in proc.wait() even after kill() — the
flow-control-paused pipe transport never sees EOF — permanently wedging
one dispatcher per stuck worker. Once all dispatchers were stuck, every
preview request timed out. Restart cleared it.

Fixes:
- Spawn workers with inherited stderr (stderr=None) so worker diagnostics
  go straight to the server log and no undrained pipe can exist.
- Bound proc.wait() in worker kill() with a 5s grace timeout so a wedged
  transport can never hang a dispatcher; log the worker pid instead.
- Guard the dispatch loop with an outer exception handler so a dispatcher
  can never die silently and shrink pool capacity.
- Retry failed worker replacement spawns with 1s-30s backoff instead of
  silently shrinking the pool.
- Fix latent crash: except-tuple referenced msgspec.json.DecodeError,
  which does not exist in the installed msgspec; any protocol failure
  would itself raise AttributeError. Use msgspec.DecodeError.
- Worker: redirect Python-level sys.stdout to stderr in persistent mode
  and keep the raw buffer solely for the binary protocol, so a library
  print() can never corrupt the command channel again (cf. the pymupdf
  deprecation warning that crashed workers at startup).
- Worker: close the pymupdf document explicitly in process_pdf.
- Log worker pid on timeout/protocol/checksum failures, and log failed
  kills and replacement retries, for future production diagnostics.

Add tests/test_preview_pool.py with an end-to-end regression recreating
the wedged-worker setup (piped, undrained stderr) plus kill-grace,
respawn-retry and dispatcher-survival tests.
2026-08-11 04:57:20 +00:00
LeoVasanko c025e7af95 Fix deprecation warning from fitz being renamed to pymupdf: update import. 2026-08-11 02:23:48 +00:00
LeoVasanko 3bad311e35 Tell Paskia SSO not to renew session on WebSocket connections where we cannot pass back the refreshed cookie. 2026-08-11 02:08:01 +00:00
LeoVasanko fdc4fe0a3e Forward client user-agent to SSO backend on validation refreshes. Matches function of existing proxy_auth_request (copies all headers) and proxy_auth_websocket (copies user-agent when present). 2026-08-10 14:08:17 +00:00
LeoVasanko 5df2308bdb Silence CPY copyright rule; make format_access_log tail args keyword-only
Newer ruff flagged CPY001 across the codebase (copyright notices are not
wanted here, rule disabled) and PLR0917 on format_access_log. duration_ms
and extra are now keyword-only at the single call site.
2026-07-28 02:37:12 +00:00
LeoVasanko f4c44ce1aa Remove unused frontend test framework
vitest, @vue/test-utils, jsdom and @types/jsdom were installed but no
frontend tests exist or are planned. Removing them also drops the
deprecated glob@10 dependency chain (js-beautify). type-check now uses
tsconfig.app.json.
2026-07-28 02:32:26 +00:00
LeoVasanko 49232f11cc Fix rename flow: KeepAlive-cached view watchers cleared cursor on stale props
Deactivated FileExplorer/Gallery instances stay alive in KeepAlive with
frozen, potentially empty document props. Their empty-folder watcher
cleared store.cursor and yanked focus to the breadcrumb on every cursor
change, breaking rename via gallery pen and keyboard entry into the
file list, and hiding the explorer rename button.

- Guard cursor watchers in FileExplorer/Gallery with an isActive flag
  (set on activated, cleared on deactivated)
- Declare emits in GalleryFigure (rename/menu fell through to the root
  anchor as native listeners)
- Show the explorer rename button on row hover with a delayed fade-in
  instead of only on the keyboard-focused row
2026-07-28 02:16:23 +00:00
LeoVasanko 1258eff42d Fix preview worker pool leak: ffmpeg must not inherit worker stdin
The ffmpeg fallback in the preview worker inherited the worker's stdin
pipe (the framed request protocol). When a slow conversion was killed
at the 10s timeout, the orphaned ffmpeg grandchild kept that pipe open,
so the parent's proc.wait() blocked forever waiting for pipe EOF —
permanently sticking one dispatcher per event until the whole pool
starved and every preview request (pdf, image, office) returned 503.

- Run ffmpeg with stdin=DEVNULL (also stops it eating protocol bytes)
- Drop start_new_session (only needed for group kills, POSIX-only)
- Stop logging the master secret at worker startup
2026-07-28 01:14:15 +00:00
LeoVasanko 718d46e3f9 Fix search in subdirectories (problem saving search field in URL). 2026-06-17 04:01:21 +00:00
LeoVasanko 92d9c40a28 Center file rename input in gallery mode to be more consistent with normal titles. 2026-06-17 03:43:01 +00:00
LeoVasanko 4f646fb344 Fix layout when there is more space than needed to display file explorer (don't scale larger) or gallery (don't bottom align). 2026-06-17 03:30:20 +00:00
LeoVasanko d6304d0029 Frontend linter changes. 2026-06-16 22:13:27 +00:00
LeoVasanko 77e35cf0fc fix(frontend): new file and folder creation hang, empty folder UX
- Replace circular watchEffects in FileExplorer/Gallery with explicit watchers

  to stop recursive Vue updates when creating items in empty folders.

- Move EmptyFolder rendering inside FileExplorer/Gallery so empty/list swaps

  no longer trigger folder slide transitions.

- Keep EmptyFolder text size consistent across list and gallery views.
2026-06-16 22:09:53 +00:00
LeoVasanko bf8a049b92 Use canonical paths for editor and fix route transitions 2026-05-07 02:18:55 +00:00
LeoVasanko 6d7f44bd88 Add view caching and preserve folder/editor UI state 2026-05-07 02:12:03 +00:00
LeoVasanko e2097a1563 Fix empty state vertical centering in explorer 2026-05-07 01:53:16 +00:00
LeoVasanko b864936eaa Lint 2026-05-07 01:44:27 +00:00
LeoVasanko 72b3c0d8ce feat(frontend): add text editor flow and create-file UX
- replace textarea editor with CodeMirror and syntax highlighting\n- integrate editor mode header (save button, hide unused controls)\n- fix breadcrumb/editor navigation behavior and transitions\n- add Create File action with ghost-rename flow and auto-open in editor\n- refine create-file icon shape and plus cutout alignment
2026-05-06 02:17:17 +00:00
LeoVasanko 07daf372e8 Rudimentary text file editing support. 2026-05-06 00:20:19 +00:00
LeoVasanko e979d679b2 feat(frontend): add simultaneous slide transitions on directory navigation
- Detect navigation direction (forward/backward) via router beforeEach guard
- Store transition direction in Pinia for cross-component access
- Wrap ExplorerView content in a CSS grid transition wrapper so old and new
  views overlap in the same grid cell during animation
- Add slide-forward/slide-backward transition classes with translate3d
  for GPU-accelerated, simultaneous enter/leave without gaps
- Keep fixed-position search loader outside the transition
2026-05-05 16:07:07 +00:00
LeoVasanko eea66c0013 Rename selection clear button label 2026-05-05 03:00:10 +00:00
LeoVasanko 9220c457c0 Use link icon for the share link button. 2026-05-05 02:58:43 +00:00
LeoVasanko d5b77932ea Allow anonymous share links in public mode 2026-05-05 02:47:18 +00:00
LeoVasanko 9b9d3e1cc1 Adjust gallery checkbox size and spacing 2026-05-05 02:38:25 +00:00
LeoVasanko 536efc4ce1 Regression: inotify optional on unsupported platforms 2026-05-05 02:32:47 +00:00
LeoVasanko 1b2267587f Fix empty root shown as missing folder 2026-05-05 02:26:41 +00:00
LeoVasanko 2864e9f041 Hide tiny Other slice in disk space chart 2026-05-05 02:20:55 +00:00
LeoVasanko b5a94b5eee Frontend upload batch space check with 512 MiB margin
- Verify single queue: uploads append to upqueue and are processed
  sequentially by one worker (parallel only within a single file).
- Before accepting a new batch, calculate total size minus existing
  files that will be overwritten.
- Reject the whole batch if free space < net need + 512 MiB margin.
- Show a toast with human-readable needed vs available space.
2026-05-05 02:13:18 +00:00
LeoVasanko d4be755d46 Enforce 128 MiB minimum free space on uploads
- Add cista/util/diskspace.py with MIN_FREE_BYTES limit and cached
  check_free_space() helper.
- Check available space in File.write() before ftruncate/write.
- Catch ENOSPC in File.write and re-raise as InsufficientStorageError.
- upload_file_chunk catches both proactive and ENOSPC errors and
  returns HTTP 507 Insufficient Storage.
- Add tests for low-disk rejection and ENOSPC handling.
2026-05-05 01:46:30 +00:00
LeoVasanko 07089aa9a7 frontend: add About dialog and unify global dialog backdrop 2026-05-03 23:40:54 +00:00
LeoVasanko c3146744b7 PageUp/PgDn/Home/End navigation; fix rename API path; immediate local rename update 2026-05-03 22:58:51 +00:00
LeoVasanko 48d7435d0b Allow portrait documents to render somewhat taller because gaps were being left between them depending on window width. 2026-05-03 18:36:14 +00:00
LeoVasanko 3e80325053 Smoother scrolling in gallery when using keyboard navigation. 2026-05-03 18:33:19 +00:00
LeoVasanko 31fc02ddbf Keep the file extension always visible but discreetly as part of the filename. Fix Escape in rename field. 2026-05-03 18:22:03 +00:00
LeoVasanko 2406ea87b0 Gallery mode file extension and filename display improvements. 2026-05-03 17:26:25 +00:00
LeoVasanko 3a1dd2b7da Show document icon when no preview is available or has failed. While loading, pulse the icon. 2026-05-02 19:54:38 +00:00
LeoVasanko 3df6b079c9 Better logging of ffmpeg errors 2026-05-02 19:49:06 +00:00
LeoVasanko af804e2c9f Less noisy preview timeout logging 2026-05-02 19:30:16 +00:00
LeoVasanko 1dc0c4441a Fixes on server startup and shutdown with Sanic internals. Parallel shutdown tasks as non-blocking async tasks to avoid blocking the event loop and causing a warning of that. 2026-05-02 19:22:23 +00:00
LeoVasanko c7ba0d5a04 Dynamically adjusting layout to maximize screen space used for document previews. Row width changes with aspect ratio of items on that row. Server side tracking of size as part of the main listing. 2026-05-02 18:45:44 +00:00
LeoVasanko 0071058b29 Avoid gallery layout collapse while previews are still loading. Maintain the boxes fixed size regardless. 2026-05-02 16:19:42 +00:00
LeoVasanko 338c74de69 Avoid noisy output on normal WebSocket closing. 2026-05-02 16:12:42 +00:00
LeoVasanko b13f08eab2 Avoid InvalidStateError on conversion tasks trying to set their result after preview cancelled. 2026-05-02 16:04:21 +00:00
LeoVasanko 5d566d6deb Restore CISTA_HOME internal environment passing of config dir removed in commit 31e0197, that was not being passed across processes. 2026-05-02 05:47:24 +00:00
LeoVasanko 5c4965a86b Skip removal of PLR2004. 2026-05-02 05:33:32 +00:00
LeoVasanko bd7291e9ef Lint: unused arguments 2026-05-02 05:26:47 +00:00
LeoVasanko 2fa52229cc Exception nazi and other suppression removals. Added tests on auth flows that were simplified to linter requirements. 2026-05-02 05:22:56 +00:00
LeoVasanko 2dea459d8f Update fastapi-vue-setup 1.3.1 to avoid ruff errors. Fixed devserver script on platforms where Sanic needs AppServer. 2026-05-02 05:03:43 +00:00
LeoVasanko 922069c603 Imports to top of file (ruff rule now). Refactor a couple of import cycles by implementing clear hierarchy of modules. 2026-05-02 04:46:34 +00:00
LeoVasanko 5c7c7343ad Fix logging regressions from unrelated changes and ruff changing emoji into i. Simplified custom logger. 2026-05-02 04:16:38 +00:00
LeoVasanko 593d16d8c5 Use tracerite for human-readable tracebacks. 2026-05-02 03:50:20 +00:00
LeoVasanko 20d8d317fa Ruff linting. 2026-05-02 03:31:51 +00:00
LeoVasanko 8d89c397a4 frontend: format store typing layout 2026-05-02 03:12:50 +00:00
LeoVasanko 1bec73f4cd onlyoffice previews much faster and more robust, added --oosetup helper, improved error/log handling 2026-05-02 03:10:17 +00:00
LeoVasanko 4dd1d4c7e6 Fix AVIF preview encoding for yuvj video frames 2026-05-02 00:37:53 +00:00
LeoVasanko 84ef91a360 preview: move OnlyOffice unavailable from warning log to access log extra 2026-05-01 23:20:01 +00:00
LeoVasanko 421d90e9c5 frontend: trim document formats, simplify type getters, use FILE_TYPES consistently
- Trim document extension list to common office formats only
  (doc/docx/xls/xlsx/ppt/pptx/odt/ods/odp/rtf), removing txt/md/csv/html/xml/etc.
- Simplify Doc getters: merge guard clauses into single returns
  (img, previewable, previewurl, ext).
- Simplify MediaPreview.preview() into single boolean expression.
- Use global FILE_TYPES instead of inline extension lists.
2026-05-01 23:17:40 +00:00
LeoVasanko 8b4e622aef preview: remove ffprobe fallback and unused json import
_get_image_dimensions now relies solely on pyvips header reading.
If pyvips cannot read the header the function returns None and the
caller encodes at full resolution rather than falling back to ffprobe.
Remove unused stdlib json import.
2026-05-01 22:51:58 +00:00
LeoVasanko 13f32c57ab preview: ffmpeg CLI fallback for HEIC/HDR, worker pool fixes, logging improvements
- Use ffmpeg CLI directly for HEIC/HEIF previews (bypassing pyvips which
  cannot encode >8-bit AVIF on current system). ffmpeg handles tile
  assembly, EXIF rotation, HDR metadata and ICC profiles automatically.
- pyvips remains primary for other image formats; ffmpeg is fallback.
- Simplify _get_image_dimensions() to use pyvips header read for all
  formats including HEIC, with ffprobe as fallback.
- Move all imports to top of preview.py; remove lazy OnlyOffice import.
- Worker pool: add readiness handshake (\x01 byte), bounded idle wait
  with PREVIEW_TIMEOUT, eager module import before signalling.
- Fix dispatch data check bug (was 'if data is not None' -> 'if data').
- Add logger.exception for unhandled preview/dispatch errors.
- EmojiFormatter now includes tracebacks for exception logs.
- Return 503 on preview timeouts instead of 504.
2026-05-01 22:48:30 +00:00
LeoVasanko 25a2a5f20c Separate OnlyOffice async handling, add office_previews WS flag, framed worker protocol
- Move OnlyOffice conversion out of worker subprocesses into async event loop
  using httpx.AsyncClient with shared client and clean shutdown hook
- Add OOConversionManager with in-flight deduplication (asyncio.Future) and
  configurable concurrency limit (OO_MAX_CONCURRENT=2)
- Add 10s total timeout for office previews via asyncio.wait_for
- Return 503 when OnlyOffice is unavailable, 504 on timeout
- Remove office handling from worker dispatch(); workers now only do
  images, video, and PDFs
- Replace PNG tempfile bridge with framed binary input protocol on worker
  stdin: (json_size)(data_size)(json)(raw_data)
- Add process_image_buffer() for in-memory AVIF conversion via pyvips
- Add office_previews to WS server message, cached every 30s from OO
  availability check
- Frontend gates only office document previews on office_previews flag;
  images, video, PDFs remain unconditional
- Change default OnlyOffice port from 8080 to 8988
2026-05-01 03:42:03 +00:00
LeoVasanko 041090cce9 preview: increase timeout to 10s, add priority queue scheduling (images > video > pdf > office) 2026-04-27 04:51:41 +00:00
LeoVasanko ec6db7b53f Add sort-order keycap hints next to search bar
Show 'Order [1] [2] [3]' keycaps to the right of the search bar
when the viewport is at least 800px wide. These visual hints
match the existing '/' search keycap style and correspond to
the existing keyboard shortcuts for sorting:
  1 = name (alphabetical)
  2 = modified (newest first)
  3 = size (largest first)

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

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

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

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

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

Also run ruff format across the codebase to satisfy linter checks.
2026-04-26 06:59:01 +00:00
LeoVasanko eb5ff82de6 frontend: add biome checks and pre-commit integration (excluding preview files) 2026-04-26 06:43:06 +00:00
LeoVasanko 942b54d795 lint: apply manual ruff cleanup (non-preview files) 2026-04-26 06:16:42 +00:00
LeoVasanko 18ee0f3f56 sso: use /auth/api/check for token permission checks 2026-04-26 05:46:09 +00:00
LeoVasanko f8b2c9494a WebDAV sync support, access tokens, REST control endpoints (#10)
Implement complete WebDAV file serving compatible with various clients from Windows File Explorer to more specialized sync tools. The old control WebSocket has been updated to part-DAV, part REST API instead. Implemented user:pass BASIC auth. Added UI and backend for creating tokens that avoid the need to use actual username and password for requests from CLI or DAV.
2026-04-26 04:22:52 +00:00
LeoVasanko 9ac571463a Uploads by PUT range requests rather than WS, remove dead code WS handlers (#9)
Increases block size to 16 MiB and adjusts progress display to work smoothly with that. Also removes download WS that was already unused. Provides faster upload speed than over WS.
2026-04-25 01:55:52 +00:00
LeoVasanko d52b24372a Remove CISTA_PIL and Pillow preview mode 2026-04-25 02:06:40 +00:00
LeoVasanko 742b3006c9 Adjust emoji log spacing 2026-04-25 01:59:14 +00:00
LeoVasanko 01a979c00a Improve preview worker error propagation and logging 2026-04-25 01:51:49 +00:00
LeoVasanko f1e16b7abe Preview: add pdf+pyvips encode path and simplify timings 2026-04-25 00:20:19 +00:00
LeoVasanko 0ebff0ec17 Preview: simplify pyvips timing to single processing ms + req total. 2026-04-25 00:06:35 +00:00
LeoVasanko c8ab06d864 Frontend: don't show img preview for video/audio/doc files
- MediaPreview.vue: only render <img> for non-video, non-audio items
  (video/audio already have their own player containers)
- Document.ts: limit previewurl to previewable files (was checking only .complete)
2026-04-24 23:54:02 +00:00
LeoVasanko d5ff7757c8 Preview: persistent worker pool, fast AVIF encode, access-log timing
- Add preview_worker.py: long-lived subprocess; JSONL request / framed binary
  response protocol (BLAKE3 checksum + LE uint32 sizes + msgspec JSON + payload)
- Add _PreviewWorkerPool: asyncio queue-based pool; kill+replace workers on timeout,
  checksum error, or protocol error; PREVIEW_TIMEOUT=3s, PREVIEW_WORKERS=cpu-scaled
- Use pyvips effort=0,strip=True for fast AVIF thumbnails; CISTA_PIL=1 env forces Pillow
- Report load/process/save ms in PreviewResponse; inject into access log via req.ctx._log_extra
- Wire start/shutdown_preview_workers into Sanic lifecycle hooks (app.py)
- Call configure_main_logging() after Sanic() to install emoji formatter
- Silence worker stderr (DEVNULL)
2026-04-24 23:53:23 +00:00
LeoVasanko cd604eb10a Logging: emoji formatter and access-log extra field 2026-04-24 23:52:34 +00:00
LeoVasanko abcf5d9940 Use client IP (potentially proxied) in logs, handle [ipv6]. 2026-04-24 20:13:11 +00:00
LeoVasanko 0190bda853 Avoid useless check. 2026-04-24 00:04:58 +00:00
LeoVasanko fc48500412 Upload end of file first for large files (for preview to function). 2026-04-24 00:02:17 +00:00
LeoVasanko d31ad0b525 Avoid showing previews while file upload is in progress or the file is incomplete. This avoids 500 errors on server trying to generate the preview. 2026-04-23 19:34:16 +00:00
LeoVasanko da4bba95be Handle missing preview files with silent 404 2026-04-23 19:02:54 +00:00
LeoVasanko e07ab220cb Add cleaner access logging for HTTP requests and WebSockets 2026-04-23 18:06:22 +00:00
LeoVasanko 3da2f6e6c3 Don't show broken media previews during uploads, instead show a spinner indicating completion. 2026-04-23 17:40:03 +00:00
LeoVasanko ded7ce65bc Faster image previews by pyvips. 2026-04-23 17:19:24 +00:00
LeoVasanko 17550be698 Allow duplicate selected entries of the same name from different paths. 2026-04-23 17:01:03 +00:00
LeoVasanko 0b269aef7f Set avif=1 flag for SVT-AV1 when encoding a single image, for increased efficiency and less memory usage. 2026-04-23 16:57:27 +00:00
104 changed files with 12291 additions and 2090 deletions
+1
View File
@@ -1,6 +1,7 @@
.* .*
*.lock *.lock
!.gitignore !.gitignore
!.pre-commit-config.yaml
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: local
hooks:
- id: ruff-check
name: ruff check
entry: uv run ruff check .
language: system
pass_filenames: false
- id: ruff-format-check
name: ruff format check
entry: uv run ruff format --check .
language: system
pass_filenames: false
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
- id: frontend-type-check
name: frontend type-check
entry: npm --prefix frontend run type-check
language: system
pass_filenames: false
- id: frontend-biome-check
name: frontend biome check
entry: npm --prefix frontend run check
language: system
pass_filenames: false
+25 -1
View File
@@ -45,7 +45,7 @@ The server remembers its settings in the config folder (default `~/.local/share/
## Authentication ## Authentication
Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode. Cista supports two authentication modes, each supporting ordinary and privileged users. Either one can be combined with the public mode.
### Public Mode ### Public Mode
@@ -83,6 +83,30 @@ In Paskia mode:
- Users with `cista:login` permission can access files - Users with `cista:login` permission can access files
- Users with `cista:admin` permission get privileged access (Admin Settings) - Users with `cista:admin` permission get privileged access (Admin Settings)
## WebDAV Access
Cista supports WebDAV, so you can mount it as a network drive or browse it directly from your operating system's file manager.
Connect to `https://cista.example.com/files/`.
### Authentication
- **Standard users:** Use your username and password with Basic auth.
- **API tokens:** For scripts, backup tools, or when your client requires NTLM (e.g. Windows File Explorer), create a token in the web interface via **🔑 API Tokens**. Authenticate with username `token` and the token secret as the password.
### Supported clients
| Client | Setup |
|--------|-------|
| **Windows File Explorer** | Map Network Drive → `https://cista.example.com/files/` (or Add a network location). Windows may try NTLM first; API tokens are recommended. |
| **macOS Finder** | Go → Connect to Server (⌘K) → `https://cista.example.com/files/` |
| **Linux (GNOME/KDE)** | Enter `davs://cista.example.com/files/` or `webdavs://cista.example.com/files/` in the location bar |
| **Android — Solid Explorer** | Tap **+** → New Cloud Connection → **WebDAV** → enter `https://cista.example.com/files/` and your credentials. |
| **Android — CX File Explorer** | Open the **Network** tab → **New location****WebDAV** → enter `https://cista.example.com/files/` and your credentials. |
| **Cyberduck, WinSCP, rclone** | Standard WebDAV profile with Basic auth |
**Note on Windows NTLM:** Windows WebDAV clients often require NTLM authentication, which is incompatible with Cista's Argon2 password hashes. API tokens solve this — Cista uses the token secret as the NTLM password.
### Internet Access ### Internet Access
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains. Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
+1 -3
View File
@@ -1,3 +1 @@
from cista._version import __version__ from cista._version import __version__ as __version__
__version__ # Public API
+38 -36
View File
@@ -5,7 +5,8 @@ from pathlib import Path
from docopt import docopt from docopt import docopt
import cista import cista
from cista import app, config, droppy, serve, server80 from cista import app, config, droppy, onlyoffice, serve, server80
from cista.sso import PASKIA_BACKEND_URL
from cista.util import pwgen from cista.util import pwgen
del app, server80.app # Only import needed, for Sanic multiprocessing del app, server80.app # Only import needed, for Sanic multiprocessing
@@ -30,14 +31,11 @@ def create_startup_box(
): ):
"""Create a framed startup box with server information.""" """Create a framed startup box with server information."""
title = f"Cista {cista.__version__}" title = f"Cista {cista.__version__}"
listen = unix if unix else url listen = unix or url
location = f"{folder} @ {listen}" location = f"{folder} @ {listen}"
lines = [title, location] lines = [title, location]
# Auth line: Paskia <url> or Password, with optional Public suffix # Auth line: Paskia <url> or Password, with optional Public suffix
if paskia_url: auth_line = f"Auth: Paskia {paskia_url}" if paskia_url else "Auth: Password"
auth_line = f"Auth: Paskia {paskia_url}"
else:
auth_line = "Auth: Password"
if public: if public:
auth_line += ", Public" auth_line += ", Public"
lines.append(auth_line) lines.append(auth_line)
@@ -49,48 +47,46 @@ def create_startup_box(
# Build the box # Build the box
box = [f"{'' * inner_width}"] box = [f"{'' * inner_width}"]
for line in lines: box.extend(f"{line:<{inner_width - 1}}" for line in lines)
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}") box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n" return "\n".join(box) + "\n"
banner = create_banner() banner = create_banner()
doc = """\ _default_confdir = (
(Path(os.environ["XDG_CONFIG_HOME"]) / "cista").as_posix()
if os.environ.get("XDG_CONFIG_HOME")
else (Path.home() / ".config/cista").as_posix()
)
doc = f"""\
Usage: Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>] cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password] cista [-c <confdir>] --user <name> [--privileged] [--password]
cista [-c <confdir>] --oosetup
cista --version cista --version
Options: Options:
-c CONFDIR Custom config directory -c CONFDIR Config directory [{_default_confdir}]
-l, --listen LISTEN-ADDR -l, --listen ADDR Listen on address (port, :port, /socket or domain for https)
Listen on --import-droppy Import Droppy config from ~/.droppy/config
:8989 (localhost port, plain http) --dev Developer mode (reloads, friendlier crashes, more logs)
<addr>:3000 (bind another address, port) --user NAME Create or modify a user account (when server is not running)
/path/to/unix.sock (unix socket) --privileged Grant admin rights
example.com (run on 80 and 443 with LetsEncrypt) --password Reset password
--import-droppy Import Droppy config from ~/.droppy/config --oosetup Build and run OnlyOffice in Docker for document previews
--dev Developer mode (reloads, friendlier crashes, more logs)
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: Environment:
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401) PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
https://git.zi.fi/leovasanko/paskia https://git.zi.fi/leovasanko/paskia
ONLYOFFICE_CISTA_URL, ONLYOFFICE_JWT_SECRET, ONLYOFFICE_CALLBACK_HOST (if needed)
""" """
first_time_help = """\ first_time_help = """\
No config file found! Get started with: No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts cista --user yourname --privileged # If you want user accounts
cista -l :8989 /path/to/files # Run the server on localhost:8989 cista -l :8989 /path/to/files # Run the server on localhost:8989
See cista --help for other options! See cista --help for other options!
""" """
@@ -119,6 +115,8 @@ def _main():
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
if args["--oosetup"]:
return onlyoffice.setup_docker(_resolve_confdir(args))
listen = args["--listen"] listen = args["--listen"]
# Validate arguments first # Validate arguments first
if args["<path>"]: if args["<path>"]:
@@ -157,9 +155,6 @@ def _main():
if not config.config.path.is_dir(): if not config.config.path.is_dir():
raise ValueError(f"No such directory: {config.config.path}") raise ValueError(f"No such directory: {config.config.path}")
dev = args["--dev"] dev = args["--dev"]
# Check for Paskia SSO
from cista.sso import PASKIA_BACKEND_URL
# Print startup box # Print startup box
startup_box = create_startup_box( startup_box = create_startup_box(
folder=config.config.path, folder=config.config.path,
@@ -175,17 +170,24 @@ def _main():
return 0 return 0
def _confdir(args): def _resolve_confdir(args):
confdir = None
if args["-c"]: if args["-c"]:
# Custom config directory # Custom config directory
confdir = Path(args["-c"]).resolve() confdir = Path(args["-c"]).resolve()
if confdir.exists() and not confdir.is_dir(): if confdir.exists() and not confdir.is_dir():
if confdir.name != config.conffile.name: if confdir.name != "db.toml":
raise ValueError("Config path is not a directory") raise ValueError("Config path is not a directory")
# Accidentally pointed to the db.toml, use parent # Accidentally pointed to the db.toml, use parent
confdir = confdir.parent confdir = confdir.parent
return confdir
def _confdir(args):
confdir = _resolve_confdir(args)
if confdir is not None:
os.environ["CISTA_HOME"] = confdir.as_posix() os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir() # Uses environ if available config.init_confdir()
def _user(args): def _user(args):
+57 -88
View File
@@ -1,16 +1,20 @@
import asyncio import asyncio
import typing
from pathlib import PurePosixPath
from secrets import token_bytes from secrets import token_bytes
import msgspec import msgspec
from sanic import Blueprint, json from sanic import Blueprint, json
from sanic.exceptions import BadRequest from sanic.exceptions import BadRequest
from sanic.log import logger
from cista import __version__, auth, config, sso, watching from cista import __version__, auth, config, onlyoffice, sharefs, sso, watching
from cista.auth import (
create_share_token_handler,
create_token_handler,
delete_token_handler,
list_tokens_handler,
)
from cista.fileio import FileServer from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg from cista.util.apphelpers import websocket_wrapper
from cista.util.apphelpers import asend, websocket_wrapper
bp = Blueprint("api", url_prefix="/api") bp = Blueprint("api", url_prefix="/api")
fileserver = FileServer() fileserver = FileServer()
@@ -18,84 +22,16 @@ fileserver = FileServer()
@bp.before_server_start @bp.before_server_start
async def start_fileserver(app): async def start_fileserver(app):
_ = app
await fileserver.start() await fileserver.start()
@bp.after_server_stop @bp.after_server_stop
async def stop_fileserver(app): async def stop_fileserver(app):
_ = app
await fileserver.stop() await fileserver.stop()
@bp.websocket("upload")
@websocket_wrapper
async def upload(req, ws):
alink = fileserver.alink
while True:
req = None
text = await ws.recv()
if not isinstance(text, str):
raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}",
)
req = msgspec.json.decode(text, type=FileRange)
pos = req.start
while True:
data = await ws.recv()
if not isinstance(data, bytes):
break
if len(data) > req.end - pos:
raise ValueError(
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
)
sentsize = await alink(("upload", req.name, pos, data, req.size))
pos += typing.cast(int, sentsize)
if pos >= req.end:
break
if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
# 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)
@bp.websocket("download")
@websocket_wrapper
async def download(req, ws):
alink = fileserver.alink
while True:
req = None
text = await ws.recv()
if not isinstance(text, str):
raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}",
)
req = msgspec.json.decode(text, type=FileRange)
pos = req.start
while pos < req.end:
end = min(req.end, pos + (1 << 20))
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
await asend(ws, data)
pos += len(data)
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
@bp.websocket("control")
@websocket_wrapper
async def control(req, ws):
while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(cmd)
# Signal the watcher about affected paths
watching.notify_change(*cmd.affected_paths())
await asend(ws, StatusMsg(status="ack", req=cmd))
@bp.websocket("watch") @bp.websocket("watch")
@websocket_wrapper @websocket_wrapper
async def watch(req, ws): async def watch(req, ws):
@@ -104,9 +40,11 @@ async def watch(req, ws):
if sso.paskia_enabled(): if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode) # SSO auth: call validation to get user info (don't enforce auth in public mode)
try: try:
await sso.validate_sso_request(req) # WebSocket cannot forward Set-Cookie, so ask the auth backend not to
except Exception: # renew the session here; renewal happens on the HTTP side instead.
pass # Ignore auth errors, user_info stays None await sso.validate_sso_request(req, renew=False)
except Exception as e:
logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None): if sso_user := getattr(req.ctx, "sso_user", None):
ctx = sso_user.get("ctx", {}) ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", []) perms = ctx.get("permissions", [])
@@ -129,21 +67,34 @@ async def watch(req, ws):
"version": __version__, "version": __version__,
"public": config.config.public, "public": config.config.public,
"paskia": sso.paskia_enabled(), "paskia": sso.paskia_enabled(),
"office_previews": await onlyoffice.is_available_cached(),
}, },
"user": user_info, "user": user_info,
} }
).decode() ).decode()
) )
uuid = token_bytes(16) uuid = token_bytes(16)
share_token = auth.request_share_token(req)
try: try:
q, space, root = await asyncio.get_event_loop().run_in_executor( q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws req.app.ctx.threadexec, subscribe, uuid, ws
) )
await ws.send(space) await ws.send(space)
await ws.send(root) if share_token is None:
await ws.send(root)
else:
await ws.send(watching.format_root(sharefs.build_virtual_root(share_token)))
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) msg = await q.get()
if share_token is None or (
isinstance(msg, str) and msg.startswith('{"space"')
):
await ws.send(msg)
else:
await ws.send(
watching.format_root(sharefs.build_virtual_root(share_token))
)
except RuntimeError as e: except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown": if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket return # Server shutting down, drop the WebSocket
@@ -153,6 +104,7 @@ async def watch(req, ws):
def subscribe(uuid, ws): def subscribe(uuid, ws):
_ = ws
with watching.state.lock: with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue() q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree # Init with disk usage and full tree
@@ -179,12 +131,10 @@ async def update_public(request):
await auth.verify(request, privileged=True) await auth.verify(request, privileged=True)
try: try:
public = request.json["public"] public = request.json["public"]
if not isinstance(public, bool):
raise ValueError("public must be a boolean")
except KeyError: except KeyError:
raise BadRequest("Missing public field") from None raise BadRequest("Missing public field") from None
except ValueError as e: if not isinstance(public, bool):
raise BadRequest(str(e)) from None raise BadRequest("public must be a boolean")
config.update_config({"public": public}) config.update_config({"public": public})
return json({"message": "Public access setting updated", "public": public}) return json({"message": "Public access setting updated", "public": public})
@@ -194,13 +144,32 @@ async def update_name(request):
await auth.verify(request, privileged=True) await auth.verify(request, privileged=True)
try: try:
name = request.json["name"] name = request.json["name"]
if not isinstance(name, str):
raise ValueError("name must be a string")
except KeyError: except KeyError:
raise BadRequest("Missing name field") from None raise BadRequest("Missing name field") from None
except ValueError as e: if not isinstance(name, str):
raise BadRequest(str(e)) from None raise BadRequest("name must be a string")
config.update_config({"name": name}) config.update_config({"name": name})
# Return the effective name (fallback to path.name if empty) # Return the effective name (fallback to path.name if empty)
effective_name = name or config.config.path.name effective_name = name or config.config.path.name
return json({"message": "Server name updated", "name": effective_name}) return json({"message": "Server name updated", "name": effective_name})
# Token management endpoints (available in all modes; primary path in SSO mode)
@bp.get("tokens")
async def list_api_tokens(request):
return await list_tokens_handler(request)
@bp.post("tokens")
async def create_api_token(request):
return await create_token_handler(request)
@bp.delete("tokens/<token_id>")
async def delete_api_token(request, token_id):
return await delete_token_handler(request, token_id)
@bp.post("share-tokens")
async def create_share_token(request):
return await create_share_token_handler(request)
+151 -78
View File
@@ -1,76 +1,64 @@
import asyncio import asyncio
import datetime import datetime
import mimetypes import mimetypes
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import sanic.helpers import tracerite
from blake3 import blake3 from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect from sanic import Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger from sanic.log import logger
from setproctitle import setproctitle from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
from cista import auth, config, preview, session, sso, watching from cista import (
auth,
config,
fileserver,
onlyoffice,
preview,
session,
sharefs,
sso,
watching,
)
from cista.api import bp from cista.api import bp
from cista.preview import shutdown_preview_workers, start_preview_workers
from cista.sanic_logging import (
configure_access_logging,
configure_main_logging,
format_access_log,
)
from cista.sanic_logging import logger as access_logger
from cista.util.apphelpers import handle_sanic_exception from cista.util.apphelpers import handle_sanic_exception
# Workaround until Sanic PR #2824 is merged tracerite.load()
sanic.helpers._ENTITY_HEADERS = frozenset() configure_access_logging()
app = Sanic("cista", strict_slashes=True) app = Sanic("cista", strict_slashes=True)
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL app.router.ALLOWED_METHODS = (
if sso.paskia_enabled(): *app.router.ALLOWED_METHODS,
app.blueprint(sso.bp) # SSO proxy for /auth/* routes "MKCOL",
else: "MOVE",
app.blueprint(auth.bp) # Built-in auth routes "COPY",
app.blueprint(preview.bp) "PROPFIND",
app.blueprint(bp) )
app.exception(Exception)(handle_sanic_exception)
configure_main_logging()
setproctitle("cista-main")
@app.before_server_start
async def main_start(app):
config.load_config()
setproctitle(f"cista {config.config.path.name}")
# Small pool for memory-intensive preview generation
preview_workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=preview_workers, thread_name_prefix="cista-preview"
)
# Larger pool for long-running but low-memory zip operations
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
watching.start(app)
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop
async def main_stop(app):
watching.stop(app)
app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client()
logger.debug("Cista worker threads all finished")
@app.on_request @app.on_request
async def use_session(req): async def use_session(req):
req.ctx.session = session.get(req) req.ctx.log_start = time.perf_counter()
try: req.ctx.auth_flow = ["session: start"]
req.ctx.username = req.ctx.session["username"] # type: ignore auth.hydrate_request_auth_context(req, source="app.on_request")
req.ctx.user = config.config.users[req.ctx.username]
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
# CSRF protection # CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket": if req.method == "GET" and req.headers.upgrade != "websocket":
return # Ordinary GET requests are fine return # Ordinary GET requests are fine
@@ -81,6 +69,29 @@ async def use_session(req):
raise Forbidden("Invalid origin: Cross-Site requests not permitted") raise Forbidden("Invalid origin: Cross-Site requests not permitted")
@app.on_response
async def log_access(req, res):
"""Log HTTP access in a clean single-line format."""
if req.headers.get("upgrade", "").lower() == "websocket":
return res
start = getattr(req.ctx, "log_start", None)
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
client = req.client_ip or "-"
host = req.host or "-"
path = req.path
if req.query_string:
qs = req.query_string
if isinstance(qs, bytes):
qs = qs.decode(errors="replace")
path = f"{path}?{qs}"
extra = getattr(req.ctx, "log_extra", None)
line = format_access_log(
client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
)
access_logger.info(line)
return res
@app.on_response @app.on_response
async def forward_sso_cookies(req, res): async def forward_sso_cookies(req, res):
"""Forward Set-Cookie headers from SSO validation to client.""" """Forward Set-Cookie headers from SSO validation to client."""
@@ -89,23 +100,65 @@ async def forward_sso_cookies(req, res):
res.headers.add("set-cookie", cookie) res.headers.add("set-cookie", cookie)
@app.on_response
async def persist_auth_session(req, res):
"""Persist a session cookie after successful Authorization-based auth."""
username = getattr(req.ctx, "create_session_username", None)
if not username or res.status >= 400:
return
existing = getattr(req.ctx, "session", None)
if isinstance(existing, dict) and existing.get("username") == username:
return
session.create(req, res, username)
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
if sso.paskia_enabled():
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
else:
app.blueprint(auth.bp) # Built-in auth routes
app.blueprint(preview.bp)
app.blueprint(bp)
app.blueprint(fileserver.bp)
app.exception(Exception)(handle_sanic_exception)
setproctitle("cista-main")
@app.before_server_start @app.before_server_start
def http_fileserver(app): async def main_start(app):
bp = Blueprint("fileserver") config.load_config()
setproctitle(f"cista {config.config.path.name}")
@bp.on_request app.ctx.threadexec = ThreadPoolExecutor(
async def verify_fileserver(request): max_workers=4, thread_name_prefix="cista-worker"
"""Verify access to file server routes."""
await auth.verify(request)
bp.static(
"/files/",
config.config.path,
use_content_range=True,
stream_large_files=True,
directory_view=True,
) )
app.blueprint(bp) # Larger pool for long-running but low-memory zip operations
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
await start_preview_workers()
watching.start(app)
@app.after_server_start
async def main_after_start(app):
_ = app
onlyoffice.log_reachable_info()
# 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):
async with asyncio.TaskGroup() as tg:
tg.create_task(asyncio.to_thread(watching.stop, app))
tg.create_task(onlyoffice.close_oo_client())
tg.create_task(shutdown_preview_workers())
tg.create_task(sso.close_client())
async with asyncio.TaskGroup() as tg:
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
logger.debug("Cista worker threads all finished")
www = {} www = {}
@@ -207,29 +260,48 @@ async def wwwroot(req, path=""):
@app.route("/favicon.ico", methods=["GET", "HEAD"]) @app.route("/favicon.ico", methods=["GET", "HEAD"])
async def favicon(req): async def favicon(req):
_ = req
# Browsers keep asking for it when viewing files (not HTML with icon link) # Browsers keep asking for it when viewing files (not HTML with icon link)
return redirect("/assets/logo-ctv8tVwU.svg", status=308) return redirect("/assets/logo-ctv8tVwU.svg", status=308)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]: def get_files(req, wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath() loc = PurePosixPath()
idx = 0 idx = 0
ret = [] ret = []
level: int | None = None level: int | None = None
parent: PurePosixPath | None = None parent: PurePosixPath | None = None
with watching.state.lock: token = auth.request_share_token(req)
root = watching.state.root
while idx < len(root): if token is None:
f = root[idx] with watching.state.lock:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name root = watching.state.root
if parent is not None and f.level <= level: while idx < len(root):
level = parent = None f = root[idx]
if f.key in wanted: loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
level, parent = f.level, loc.parent if parent is not None and f.level <= level:
if parent is not None: level = parent = None
wanted.discard(f.key) if f.key in wanted:
ret.append((loc.relative_to(parent), watching.rootpath / loc)) level, parent = f.level, loc.parent
idx += 1 if parent is not None:
wanted.discard(f.key)
ret.append((loc.relative_to(parent), watching.rootpath / loc))
idx += 1
return ret
root = sharefs.build_virtual_root(token)
while idx < len(root):
f = root[idx]
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if parent is not None and f.level <= level:
level = parent = None
if f.key in wanted:
level, parent = f.level, loc.parent
if parent is not None:
wanted.discard(f.key)
real_path = sharefs.resolve_virtual_rel_to_real(token, loc)
ret.append((loc.relative_to(parent), real_path))
idx += 1
return ret return ret
@@ -239,7 +311,7 @@ async def zip_download(req, keys, zipfile, ext):
await auth.verify(req) await auth.verify(req)
wanted = set(keys.split("+")) wanted = set(keys.split("+"))
files = get_files(wanted) files = get_files(req, wanted)
if not files: if not files:
raise NotFound( raise NotFound(
@@ -265,7 +337,8 @@ async def zip_download(req, keys, zipfile, ext):
while size > 0 and (chunk := f.read(min(size, 1 << 20))): while size > 0 and (chunk := f.read(min(size, 1 << 20))):
size -= len(chunk) size -= len(chunk)
yield chunk yield chunk
assert size == 0 if size != 0:
raise OSError(f"stream ended early while zipping {name}")
pending_put = None # Current queue.put future, can be cancelled pending_put = None # Current queue.put future, can be cancelled
+1116 -80
View File
File diff suppressed because it is too large Load Diff
+45 -6
View File
@@ -3,16 +3,19 @@ from __future__ import annotations
import os import os
import secrets import secrets
import sys import sys
from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
from time import sleep, time from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec from typing import Concatenate, Literal, ParamSpec
import msgspec import msgspec
import msgspec.toml import msgspec.toml
from .util import pwhash
class Config(msgspec.Struct): class Config(msgspec.Struct):
path: Path path: Path
@@ -22,6 +25,7 @@ class Config(msgspec.Struct):
name: str = "" name: str = ""
users: dict[str, User] = {} users: dict[str, User] = {}
links: dict[str, Link] = {} links: dict[str, Link] = {}
tokens: dict[str, Token] = {}
# Typing: arguments for config-modifying functions # Typing: arguments for config-modifying functions
@@ -43,6 +47,17 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0 expires: int = 0
class Token(msgspec.Struct, omit_defaults=True):
key: str = "" # plain text secret (shown once on creation)
username: str = "" # set in built-in mode
sso_user_id: str = "" # set in SSO mode
name: str = ""
created: int = 0
kind: str = "api" # api | share
mode: str = "rw" # ro | rw
share_paths: list[str] = []
# Global variables - initialized during application startup # Global variables - initialized during application startup
config: Config config: Config
conffile: Path conffile: Path
@@ -63,7 +78,7 @@ def init_confdir() -> None:
conffile = home / "db.toml" conffile = home / "db.toml"
def derived_secret(*params, len=8) -> bytes: def derived_secret(*params, size=8) -> bytes:
"""Used to derive secret keys from the main secret""" """Used to derive secret keys from the main secret"""
# Each part is made the same length by hashing first # Each part is made the same length by hashing first
combined = b"".join( combined = b"".join(
@@ -71,7 +86,7 @@ def derived_secret(*params, len=8) -> bytes:
for p in [config.secret, *params] for p in [config.secret, *params]
) )
# Output a bytes of the desired length # Output a bytes of the desired length
return sha256(combined).digest()[:len] return sha256(combined).digest()[:size]
def enc_hook(obj): def enc_hook(obj):
@@ -186,9 +201,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
except KeyError: except KeyError:
u = User() u = User()
if "password" in changes: if "password" in changes:
from . import auth pwhash.set_password(u, changes["password"])
auth.set_password(u, changes["password"])
del changes["password"] del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook) udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes) udict.update(changes)
@@ -204,3 +217,29 @@ def del_user(conf: Config, name: str) -> Config:
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"].pop(name) settings["users"].pop(name)
return msgspec.convert(settings, Config, dec_hook=dec_hook) return msgspec.convert(settings, Config, dec_hook=dec_hook)
@modifies_config
def update_token(conf: Config, token_id: str, changes: dict) -> Config:
"""Create or update a token."""
try:
t = msgspec.convert(
msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook),
Token,
dec_hook=dec_hook,
)
except KeyError:
t = Token()
tdict = msgspec.to_builtins(t, enc_hook=enc_hook)
tdict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
@modifies_config
def del_token(conf: Config, token_id: str) -> Config:
"""Delete a token by its stable id."""
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["tokens"].pop(token_id, None)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
+70
View File
@@ -0,0 +1,70 @@
# Patched OnlyOffice Document Server with configurable converter worker count.
#
# The Community Edition hardcodes the document converter to 1 worker,
# which creates a severe bottleneck under concurrent load.
# This image patches the open-source license.js to spawn a configurable
# number of converter workers (default 8).
#
# Build:
# docker build -t onlyoffice-cista docker/onlyoffice-converter-patch
#
# Run:
# docker run -d -p 8988:80 \
# -e WORKERS=16 \
# -e JWT_SECRET=your-strong-secret \
# --name onlyoffice onlyoffice-cista
#
# JWT:
# Set JWT_SECRET to the same value you pass to Cista as ONLYOFFICE_JWT_SECRET.
# OnlyOffice will enable token validation automatically.
#
# The ONLYOFFICE_VERSION build arg lets you target a specific release.
ARG ONLYOFFICE_VERSION=9.3.1
FROM onlyoffice/documentserver:${ONLYOFFICE_VERSION}
# Prevent interactive apt prompts
ENV DEBIAN_FRONTEND=noninteractive
# Install Node.js, npm, and git so we can run the FileConverter from source.
RUN apt-get update -qq && \
apt-get install -y -qq --no-install-recommends \
nodejs \
npm \
git \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Clone the open-source server components (shallow, ~15 MB).
# The master branch is used because the Linux/web tags are not published
# in the server repo; the license.js file has been stable for years.
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
# Patch license.js so the converter worker count is read from an env var
# instead of being hardcoded to 1.
RUN sed -i \
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
/opt/oo-server/Common/sources/license.js
# Install npm dependencies for the modules the FileConverter touches.
# DocService deps are also needed because converter.js pulls in baseConnector.
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
RUN cd /opt/oo-server/FileConverter && npm ci --no-audit --no-fund
RUN cd /opt/oo-server/DocService && npm ci --no-audit --no-fund
# Back up the compiled pkg binary and replace it with our wrapper.
RUN mv /var/www/onlyoffice/documentserver/server/FileConverter/converter \
/var/www/onlyoffice/documentserver/server/FileConverter/converter.orig
COPY converter-wrapper.sh /var/www/onlyoffice/documentserver/server/FileConverter/converter
RUN chmod +x /var/www/onlyoffice/documentserver/server/FileConverter/converter
# Default worker count (override at runtime with -e WORKERS=16).
ENV WORKERS=8
# Use our custom entrypoint to persist the env var to a file that the
# non-root converter process (user=ds) can read.
COPY entrypoint.sh /app/ds/run-document-server-patched.sh
RUN chmod +x /app/ds/run-document-server-patched.sh
ENTRYPOINT ["/app/ds/run-document-server-patched.sh"]
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Wrapper that runs the OnlyOffice FileConverter from patched Node.js source.
# Replaces the compiled pkg binary shipped with the Community Edition.
# The env var is not passed through supervisor to the 'ds' user, so we read
# it from a file written by the custom entrypoint.
if [ -z "${WORKERS}" ] && [ -r /tmp/oo-converter-workers.txt ]; then
export WORKERS=$(cat /tmp/oo-converter-workers.txt)
fi
cd /opt/oo-server/FileConverter || exit 1
export NODE_ENV=production-linux
export NODE_CONFIG_DIR=/etc/onlyoffice/documentserver
export NODE_DISABLE_COLORS=1
export APPLICATION_NAME=onlyoffice
export LD_LIBRARY_PATH=/var/www/onlyoffice/documentserver/server/FileConverter/bin
exec node sources/convertermaster.js "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Custom entrypoint that persists WORKERS to a file readable by
# the non-root user that supervisor uses to run the converter.
echo "${WORKERS:-8}" > /tmp/oo-converter-workers.txt
chmod 644 /tmp/oo-converter-workers.txt
exec /app/ds/run-document-server.sh "$@"
+1 -1
View File
@@ -17,7 +17,7 @@ def _droppy_listeners(cf):
for listener in cf["listeners"]: for listener in cf["listeners"]:
try: try:
if listener["protocol"] == "https": if listener["protocol"] == "https":
# TODO: Add support for TLS # TLS listeners are currently ignored here.
continue continue
socket = listener.get("socket") socket = listener.get("socket")
if socket: if socket:
+48 -38
View File
@@ -1,9 +1,11 @@
import asyncio import errno
import os import os
import threading
from pathlib import Path
from cista import config from cista import config
from cista.util import filename from cista.util import filename
from cista.util.asynclink import AsyncLink from cista.util.diskspace import InsufficientStorageError, check_free_space
from cista.util.lrucache import LRUCache from cista.util.lrucache import LRUCache
@@ -32,20 +34,34 @@ class File:
if not self.writable: if not self.writable:
# Create/open file # Create/open file
self.open_rw() self.open_rw()
assert self.fd is not None if self.fd is None:
raise RuntimeError("file descriptor is not available for write")
check_free_space(self.path)
if file_size is not None: if file_size is not None:
assert pos + len(buffer) <= file_size if pos + len(buffer) > file_size:
os.ftruncate(self.fd, file_size) raise ValueError("write exceeds declared file size")
try:
os.ftruncate(self.fd, file_size)
except OSError as e:
if e.errno == errno.ENOSPC:
raise InsufficientStorageError("No space left on device") from e
raise
if buffer: if buffer:
os.lseek(self.fd, pos, os.SEEK_SET) os.lseek(self.fd, pos, os.SEEK_SET)
os.write(self.fd, buffer) try:
os.write(self.fd, buffer)
except OSError as e:
if e.errno == errno.ENOSPC:
raise InsufficientStorageError("No space left on device") from e
raise
def __getitem__(self, slice): def __getitem__(self, slc):
if self.fd is None: if self.fd is None:
self.open_ro() self.open_ro()
assert self.fd is not None if self.fd is None:
os.lseek(self.fd, slice.start, os.SEEK_SET) raise RuntimeError("file descriptor is not available for read")
size = slice.stop - slice.start os.lseek(self.fd, slc.start, os.SEEK_SET)
size = slc.stop - slc.start
data = os.read(self.fd, size) data = os.read(self.fd, size)
if len(data) < size: if len(data) < size:
raise EOFError("Error reading requested range") raise EOFError("Error reading requested range")
@@ -62,38 +78,32 @@ class File:
class FileServer: class FileServer:
async def start(self): async def start(self):
self.alink = AsyncLink()
self.worker = asyncio.get_event_loop().run_in_executor(
None,
self.worker_thread,
self.alink.to_sync,
)
self.cache = LRUCache(File, capacity=10, maxage=5.0) self.cache = LRUCache(File, capacity=10, maxage=5.0)
self.cache_lock = threading.Lock()
self.file_locks: dict[str, threading.Lock] = {}
async def stop(self): async def stop(self):
await self.alink.stop() self.cache.close()
await self.worker
def worker_thread(self, slink): @staticmethod
def _stat_size(path):
try: try:
for req in slink: return Path(path).stat().st_size
with req as (command, *args): except FileNotFoundError:
if command == "upload": return None
req.set_result(self.upload(*args))
elif command == "download":
req.set_result(self.download(*args))
else:
raise NotImplementedError(f"Unhandled {command=} {args}")
finally:
self.cache.close()
def upload(self, name, pos, data, file_size): def upload_info(self, name, pos, data, file_size):
name = filename.sanitize(name) name = filename.sanitize(name)
f = self.cache[name] with self.cache_lock:
f.write(pos, data, file_size=file_size) f = self.cache[name]
return len(data) lock = self.file_locks.setdefault(name, threading.Lock())
with lock:
def download(self, name, start, end): size_before = self._stat_size(f.path)
name = filename.sanitize(name) f.write(pos, data, file_size=file_size)
f = self.cache[name] size_after = self._stat_size(f.path)
return f[start:end] return {
"written": len(data),
"created": size_before is None,
"size_before": size_before,
"size_after": size_after,
}
+680
View File
@@ -0,0 +1,680 @@
import asyncio
import contextlib
import errno
import mimetypes
import os
import re
import shutil
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from urllib.parse import quote as url_quote
from urllib.parse import unquote, urlparse
from wsgiref.handlers import format_date_time
from sanic import Blueprint, HTTPResponse, empty, json
from sanic.exceptions import BadRequest, NotFound, SanicException
from cista import auth, config, sharefs, watching
from cista.api import fileserver
from cista.util import filename
from cista.util.diskspace import InsufficientStorageError
bp = Blueprint("fileserver", url_prefix="/files")
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
_FILE_CHUNK_SIZE = 1 << 20
_DAV_NS = "DAV:"
ET.register_namespace("D", _DAV_NS)
def _dav_tag(name: str) -> str:
return f"{{{_DAV_NS}}}{name}"
@bp.on_request
async def verify_fileserver(request):
"""Verify access to file server routes."""
await auth.verify(request)
@bp.put("/<name:path>")
async def upload_file_chunk(request, name):
auth.ensure_write_allowed(request)
body = request.body
header = request.headers.get("content-range")
if header:
start, end, total = _parse_content_range(header, len(body))
else:
start = 0
end = len(body)
total = end
rel, path = _safe_relpath(name, request=request)
rel_name = rel.as_posix()
try:
upload_info = await asyncio.to_thread(
fileserver.upload_info,
rel_name,
start,
body,
total,
)
except InsufficientStorageError as e:
raise SanicException(str(e), status_code=507, quiet=True) from e
except OSError as e:
if e.errno == errno.ENOSPC:
raise SanicException(
"No space left on device", status_code=507, quiet=True
) from e
raise
extras = []
chunk_len = end - start
whole_file = start == 0 and end == total
if not whole_file:
start_mib = _to_mib_int(start)
chunk_mib = _to_mib_int(chunk_len)
# Keep range logs compact for fixed-size upload blocks.
if chunk_mib == 16:
extras.append(f"{start_mib}MiB")
else:
extras.append(f"{start_mib}+{chunk_mib}MiB")
if upload_info.get("created"):
extras.append(f"created {_to_mib_int(total)}MiB")
size_before = upload_info.get("size_before")
size_after = upload_info.get("size_after")
if size_before is not None and size_after is not None and size_before != size_after:
extras.append("resized")
request.ctx.log_extra = " ".join(extras) if extras else None
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return json(
{
"status": "ack",
"req": {
"name": rel_name,
"size": total,
"start": start,
"end": end,
},
}
)
@bp.delete("/<name:path>")
async def delete_file(request, name):
auth.ensure_write_allowed(request)
rel, path = _safe_relpath(name, request=request)
if not rel.parts:
raise BadRequest("Refusing to delete root folder")
def _delete():
if not path.exists():
raise NotFound(f"File not found: {name}")
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
await asyncio.to_thread(_delete)
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return empty(status=204)
@bp.route("/<name:path>", methods=["MKCOL"])
async def create_folder(request, name):
auth.ensure_write_allowed(request)
rel, path = _safe_relpath(name, request=request)
if not rel.parts:
raise BadRequest("Refusing to create root folder")
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False)
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return empty(status=201)
@bp.post("/", name="post_root", strict_slashes=False)
@bp.post("/<name:path>", name="post_path")
async def copy_or_move(request, name=""):
auth.ensure_write_allowed(request)
provided_args = set(request.args.keys())
if not provided_args:
raise BadRequest("No query arguments passed")
allowed_args = {"cp", "mv"}
unknown_args = sorted(provided_args - allowed_args)
if unknown_args:
raise BadRequest(f"Unknown query parameter(s): {', '.join(unknown_args)}")
mv_vals = request.args.getlist("mv")
cp_vals = request.args.getlist("cp")
mv_keys: list[str] = []
for value in mv_vals:
mv_keys.extend(k for k in value.split() if k)
cp_keys: list[str] = []
for value in cp_vals:
cp_keys.extend(k for k in value.split() if k)
if not mv_keys and not cp_keys:
raise BadRequest("No keys given")
dst_rel, dst_abs = _safe_relpath(name, request=request)
dst_exists = dst_abs.exists()
dst_is_dir = dst_exists and dst_abs.is_dir()
ordered_keys = cp_keys + mv_keys
key_paths = _get_key_paths(request, set(ordered_keys))
missing = [key for key in ordered_keys if key not in key_paths]
if missing:
raise NotFound("Files not found", context={"missing": missing})
# Validate target shape/type before mutating anything.
for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
if len(op_keys) > 1 and not dst_is_dir:
raise BadRequest(
"Destination must be an existing directory for multiple keys"
)
if not op_keys:
continue
if not dst_is_dir:
if not dst_rel.parts:
raise BadRequest("Destination file path is required")
parent_abs = dst_abs.parent
if not parent_abs.is_dir():
raise BadRequest("Destination parent folder does not exist")
if dst_exists and dst_abs.is_file():
for key in op_keys:
src_abs = _resolve_from_relpath(key_paths[key])
if src_abs.is_dir():
raise BadRequest(
"Cannot move/copy a directory to an existing file"
)
changed: set[PurePosixPath] = set()
completed: list[dict[str, str]] = []
class _FileOpError(Exception):
def __init__(self, op_name: str, key: str, error: Exception):
self.op_name = op_name
self.key = key
self.error = error
super().__init__(str(error))
def _apply():
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
for key in op_keys:
try:
src_rel = key_paths[key]
src_abs = _resolve_from_relpath(src_rel, request=request)
if dst_is_dir:
dst_item_rel = (
dst_rel / src_rel.name
if dst_rel.parts
else PurePosixPath(src_rel.name)
)
else:
dst_item_rel = dst_rel
dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
if op_name == "mv":
# A no-op rename should still return success.
if src_abs != dst_item_abs:
shutil.move(src_abs, dst_item_abs)
changed.add(src_rel)
changed.add(src_rel.parent)
elif src_abs.is_dir():
shutil.copytree(
src_abs,
dst_item_abs,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
)
else:
shutil.copy2(src_abs, dst_item_abs)
changed.add(dst_item_rel)
changed.add(dst_item_rel.parent)
completed.append({"op": op_name, "key": key})
except Exception as e:
raise _FileOpError(op_name, key, e) from e
try:
await asyncio.to_thread(_apply)
except _FileOpError as e:
raise BadRequest(
"File operation failed after partial progress",
context={
"failed_op": e.op_name,
"failed_key": e.key,
"error": str(e.error),
"completed": completed,
},
) from e
notify_paths = [p for p in changed if p.parts]
if notify_paths:
real_notify_paths: list[PurePosixPath] = []
for p in notify_paths:
real_abs = _resolve_from_relpath(p, request=request)
real_notify_paths.append(
PurePosixPath(
real_abs.relative_to(config.config.path.resolve()).as_posix()
)
)
watching.notify_change(*real_notify_paths)
return json(
{
"status": "ack",
"counts": {"cp": len(cp_keys), "mv": len(mv_keys)},
}
)
@bp.get("/<name:path>")
async def get_file(request, name=""):
return await _send_static_file(request, name, head_only=False)
@bp.head("/<name:path>")
async def head_file(request, name=""):
return await _send_static_file(request, name, head_only=True)
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
async def dav_options(request, name=""):
_ = request
_ = name
return HTTPResponse(
status=200,
headers={
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
"DAV": "1",
"MS-Author-Via": "DAV",
},
)
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
async def dav_propfind(request, name=""):
rel, path = _safe_relpath(name, request=request)
token = auth.request_share_token(request)
if token is not None and not rel.parts:
base = config.config.path.resolve()
entries = [_propfind_entry(PurePosixPath(), base)]
depth = request.headers.get("depth", "1").strip()
if depth == "infinity":
return HTTPResponse(status=403)
if depth == "1":
for root in sharefs.build_share_roots(token):
child_abs = (base / root.real_rel).resolve()
if not child_abs.exists() or not child_abs.is_relative_to(base):
continue
with contextlib.suppress(OSError):
entries.append(
_propfind_entry(PurePosixPath(root.alias), child_abs)
)
return HTTPResponse(
body=_build_propfind_xml(entries),
status=207,
content_type='application/xml; charset="utf-8"',
)
if not path.exists():
raise NotFound(f"Not found: {name}")
depth = request.headers.get("depth", "1").strip()
if depth == "infinity":
return HTTPResponse(status=403)
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
return HTTPResponse(
body=_build_propfind_xml(entries),
status=207,
content_type='application/xml; charset="utf-8"',
)
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
async def dav_copy(request, name=""):
auth.ensure_write_allowed(request)
dest_header = request.headers.get("destination")
if not dest_header:
raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
_src_rel, src_abs = _safe_relpath(name, request=request)
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
if auth.request_share_token(request) is not None and not dst_rel.parts:
raise BadRequest("Destination cannot be virtual root")
request.ctx.log_extra = f"{dst_rel}"
if not src_abs.exists():
raise NotFound(f"Source not found: {name}")
if src_abs == dst_abs:
raise BadRequest("Source and destination are the same")
dst_existed = dst_abs.exists()
if dst_existed and not overwrite:
return HTTPResponse(status=412)
if not dst_abs.parent.is_dir():
return HTTPResponse(status=409)
def _do_copy():
if dst_existed:
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
if src_abs.is_dir():
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
else:
shutil.copy2(src_abs, dst_abs)
await asyncio.to_thread(_do_copy)
real_dst_rel = PurePosixPath(
dst_abs.relative_to(config.config.path.resolve()).as_posix()
)
watching.notify_change(real_dst_rel, *real_dst_rel.parents)
return HTTPResponse(status=201 if not dst_existed else 204)
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
async def dav_move(request, name=""):
auth.ensure_write_allowed(request)
dest_header = request.headers.get("destination")
if not dest_header:
raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
_src_rel, src_abs = _safe_relpath(name, request=request)
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
if auth.request_share_token(request) is not None and not dst_rel.parts:
raise BadRequest("Destination cannot be virtual root")
request.ctx.log_extra = f"{dst_rel}"
if not src_abs.exists():
raise NotFound(f"Source not found: {name}")
if src_abs == dst_abs:
return HTTPResponse(status=204)
dst_existed = dst_abs.exists()
if dst_existed and not overwrite:
return HTTPResponse(status=412)
if not dst_abs.parent.is_dir():
return HTTPResponse(status=409)
def _do_move():
if dst_existed:
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
shutil.move(src_abs, dst_abs)
await asyncio.to_thread(_do_move)
real_src_rel = PurePosixPath(
src_abs.relative_to(config.config.path.resolve()).as_posix()
)
real_dst_rel = PurePosixPath(
dst_abs.relative_to(config.config.path.resolve()).as_posix()
)
watching.notify_change(
real_src_rel, *real_src_rel.parents, real_dst_rel, *real_dst_rel.parents
)
return HTTPResponse(status=201 if not dst_existed else 204)
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
if m is None:
raise BadRequest("Invalid Content-Range format")
start, end_inclusive, total = (int(v) for v in m.groups())
if total <= 0:
raise BadRequest("Invalid Content-Range total size")
if start > end_inclusive:
raise BadRequest("Invalid Content-Range range")
if end_inclusive >= total:
raise BadRequest("Content-Range exceeds total size")
expected_len = end_inclusive - start + 1
if expected_len != body_len:
raise BadRequest(
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
)
return start, end_inclusive + 1, total
def _to_mib_int(value_bytes: int) -> int:
return round(value_bytes / (1 << 20))
def _safe_relpath(path: str, *, request=None) -> tuple[PurePosixPath, Path]:
"""Resolve a user path under storage root and enforce containment."""
token = auth.request_share_token(request) if request is not None else None
if token is not None:
vrel, _rrel, resolved, is_root = sharefs.resolve_virtual_path(token, path)
if is_root:
return vrel, config.config.path.resolve()
return vrel, resolved
base = config.config.path.resolve()
try:
sanitized = filename.sanitize(unquote(path))
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
resolved = (base / sanitized).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
rel = PurePosixPath(resolved.relative_to(base).as_posix())
return rel, resolved
def _resolve_from_relpath(rel: PurePosixPath, *, request=None) -> Path:
"""Resolve a relative path under storage root and enforce containment."""
token = auth.request_share_token(request) if request is not None else None
if token is not None:
return sharefs.resolve_virtual_rel_to_real(token, rel)
base = config.config.path.resolve()
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
return resolved
async def _send_static_file(request, name: str, *, head_only: bool):
_, path = _safe_relpath(name, request=request)
try:
st = await asyncio.to_thread(path.stat)
except FileNotFoundError:
raise NotFound(f"File not found: {name}") from None
if path.is_dir():
raise NotFound(f"Not a file: {name}")
size = st.st_size
start = 0
end_excl = size
status = 200
range_header = request.headers.get("range")
if range_header is not None:
parsed = _parse_range_header(range_header, size)
if parsed is None:
return empty(
status=416,
headers={
"accept-ranges": "bytes",
"content-range": f"bytes */{size}",
},
)
start, end_excl = parsed
status = 206
length = end_excl - start
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
headers = {
"accept-ranges": "bytes",
"cache-control": "no-cache",
"content-length": str(length),
"content-type": mime,
"last-modified": format_date_time(st.st_mtime),
}
if status == 206:
headers["content-range"] = f"bytes {start}-{end_excl - 1}/{size}"
if head_only:
return empty(status=status, headers=headers)
res = await request.respond(status=status, headers=headers)
fd = await asyncio.to_thread(os.open, path, os.O_RDONLY)
try:
pos = start
while pos < end_excl:
chunk = await asyncio.to_thread(
os.pread,
fd,
min(_FILE_CHUNK_SIZE, end_excl - pos),
pos,
)
if not chunk:
break
pos += len(chunk)
await res.send(chunk)
finally:
await asyncio.to_thread(os.close, fd)
def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
value = header.strip()
if "," in value:
return None
m = _RANGE_RE.fullmatch(value)
if m is None:
return None
start_s, end_s = m.groups()
if not start_s and not end_s:
return None
if start_s:
start = int(start_s)
if start >= size:
return None
end_inclusive = int(end_s) if end_s else (size - 1)
if end_inclusive < start:
return None
end_inclusive = min(end_inclusive, size - 1)
return start, end_inclusive + 1
suffix_len = int(end_s)
if suffix_len <= 0:
return None
if suffix_len >= size:
return 0, size
start = size - suffix_len
return start, size
def _get_key_paths(request, wanted: set[str]) -> dict[str, PurePosixPath]:
"""Map file keys to their current relative filesystem paths."""
token = auth.request_share_token(request)
if token is not None:
return sharefs.key_paths_for_token(token, wanted)
loc = PurePosixPath()
ret: dict[str, PurePosixPath] = {}
with watching.state.lock:
root = watching.state.root
for f in root:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if f.key in wanted and f.key not in ret:
ret[f.key] = loc
if len(ret) == len(wanted):
break
return ret
# ---------------------------------------------------------------------------
# WebDAV helpers
# ---------------------------------------------------------------------------
def _parse_webdav_destination(
dest_header: str, *, request=None
) -> tuple[PurePosixPath, Path]:
"""Parse a WebDAV Destination header and resolve it to a storage path."""
parsed = urlparse(dest_header)
raw_path = parsed.path # still percent-encoded
prefix = "/files"
if raw_path in (prefix, prefix + "/"):
rel_str = ""
elif raw_path.startswith(prefix + "/"):
rel_str = raw_path[len(prefix) + 1 :]
else:
raise BadRequest("Destination must be within /files")
return _safe_relpath(rel_str, request=request)
def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
"""Build a DAV href from a storage-relative path."""
parts = rel.parts
if not parts:
return "/files/"
encoded = "/".join(url_quote(p, safe="") for p in parts)
href = f"/files/{encoded}"
return href + "/" if is_dir else href
def _dav_xml(element: ET.Element) -> bytes:
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
return b'<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(
element, encoding="unicode"
).encode("utf-8")
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
entries = [_propfind_entry(rel, path)]
if depth == "1" and path.is_dir():
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
with contextlib.suppress(OSError):
entries.append(_propfind_entry(child_rel, child))
return entries
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
st = path.stat()
is_dir = path.is_dir()
return {
"href": _rel_to_href(rel, is_dir=is_dir),
"name": rel.parts[-1] if rel.parts else "",
"is_dir": is_dir,
"size": st.st_size,
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
"content_type": mimetypes.guess_type(path.name)[0]
or "application/octet-stream",
"last_modified": format_date_time(st.st_mtime),
"created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
}
def _build_propfind_xml(entries: list[dict]) -> bytes:
multistatus = ET.Element(_dav_tag("multistatus"))
for e in entries:
response = ET.SubElement(multistatus, _dav_tag("response"))
ET.SubElement(response, _dav_tag("href")).text = e["href"]
propstat = ET.SubElement(response, _dav_tag("propstat"))
prop = ET.SubElement(propstat, _dav_tag("prop"))
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
if e["is_dir"]:
ET.SubElement(rt, _dav_tag("collection"))
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
if not e["is_dir"]:
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
return _dav_xml(multistatus)
+319
View File
@@ -0,0 +1,319 @@
"""OnlyOffice Document Server integration for office document preview.
Provides server-side conversion of office documents to PNG via the
OnlyOffice Document Server /ConvertService.ashx API. The resulting PNG
is passed through pyvips for AVIF compression.
Environment requirements:
- OnlyOffice Document Server must be running and reachable.
- If Document Server runs in Docker, the callback host IP must be
reachable from the container (usually the docker bridge IP).
"""
import asyncio
import json
import os
import socket
import socketserver
import subprocess
import threading
import urllib.error
import urllib.request
from functools import partial
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from time import perf_counter
from urllib.parse import quote
import httpx
import jwt
from sanic.log import logger
from cista import config
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
_httpx_client: httpx.AsyncClient | None = None
def _get_onlyoffice_url() -> str:
return os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988")
def _get_jwt_secret() -> str:
return (
os.environ.get("ONLYOFFICE_JWT_SECRET")
or config.derived_secret("onlyoffice", size=16).hex()
)
def _get_callback_host() -> str:
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
return host
# Try to auto-detect docker bridge IP
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
for line in result.stdout.splitlines():
if "inet " in line:
parts = line.strip().split()
addr_part = parts[1] # e.g. 172.17.0.1/16
return addr_part.split("/")[0]
except Exception:
logger.debug("Failed to auto-detect docker bridge IP")
return "127.0.0.1"
# ---------------------------------------------------------------------------
# Async HTTP client
# ---------------------------------------------------------------------------
def get_httpx_client() -> httpx.AsyncClient:
"""Return the shared async HTTP client for OnlyOffice requests."""
global _httpx_client
if _httpx_client is None:
_httpx_client = httpx.AsyncClient()
return _httpx_client
async def close_oo_client() -> None:
"""Close the shared async HTTP client."""
global _httpx_client
if _httpx_client is not None:
await _httpx_client.aclose()
_httpx_client = None
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------
def _probe_status() -> tuple[bool, bool, str | None]:
"""Return (ok, responded, detail) for a lightweight reachability probe."""
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
try:
with urllib.request.urlopen(url, timeout=2) as resp: # noqa: S310
status = resp.status
except urllib.error.HTTPError as e:
status = e.code
except Exception:
return False, False, None
if status in (200, 405):
return True, True, None
if status >= 500:
return False, True, f"HTTP {status}"
return False, True, f"HTTP {status}"
def log_reachable_info() -> None:
"""Log info on success, warning on responded probe errors, silent on no-response."""
ok, responded, detail = _probe_status()
if ok:
logger.info("Using OnlyOffice document server at %s", _get_onlyoffice_url())
elif responded:
suffix = f": {detail}" if detail else ""
logger.warning("OnlyOffice probe failed%s", suffix)
def setup_docker(confdir: Path | None = None) -> int:
"""Build and run the patched OnlyOffice Docker image."""
if confdir is not None:
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir()
if config.conffile.exists():
config.load_config()
else:
config.update_config(
{
"listen": ":8989",
"path": Path.home() / "Downloads",
"public": False,
}
)
secret = config.derived_secret("onlyoffice", size=16).hex()
docker_dir = Path(__file__).parent / "docker"
if not docker_dir.is_dir():
raise FileNotFoundError(
f"Docker files not found at {docker_dir}. Is the package installed correctly?"
)
logger.info("Building OnlyOffice image")
build_cmd = ["docker", "build", "-t", "onlyoffice-cista", str(docker_dir)]
logger.info("%s", " ".join(build_cmd))
result = subprocess.run(build_cmd, check=False, shell=False) # noqa: S603
if result.returncode != 0:
raise RuntimeError("Failed to build OnlyOffice image")
logger.info("Starting OnlyOffice container")
run_cmd = [
"docker",
"run",
"-d",
"-p",
"8988:80",
"-e",
f"JWT_SECRET={secret}",
"-e",
"WORKERS=8",
"--name",
"onlyoffice-cista",
"--restart",
"unless-stopped",
"onlyoffice-cista",
]
logger.info("%s", " ".join(run_cmd))
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
if result.returncode != 0:
raise RuntimeError("Failed to start OnlyOffice container")
logger.info("OnlyOffice is running on http://localhost:8988")
return 0
async def is_available_async(request_timeout: float = 2.0) -> bool:
"""Return True if the configured OnlyOffice Document Server is reachable."""
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
client = get_httpx_client()
try:
response = await client.get(url, timeout=request_timeout)
except Exception:
return False
else:
return response.status_code in (200, 405)
_oo_available_cache: tuple[bool, float] | None = None
OO_AVAILABILITY_CACHE_TTL = 30.0
async def is_available_cached() -> bool:
"""Return cached OnlyOffice availability, refreshed every 30 seconds."""
global _oo_available_cache
now = perf_counter()
if _oo_available_cache is not None:
result, timestamp = _oo_available_cache
if now - timestamp < OO_AVAILABILITY_CACHE_TTL:
return result
result = await is_available_async()
_oo_available_cache = (result, now)
return result
# ---------------------------------------------------------------------------
# Temporary HTTP server so OnlyOffice can download the file
# ---------------------------------------------------------------------------
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args) -> None:
pass
def _get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
return s.getsockname()[1]
def _serve_file_temporarily(file_path: Path):
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
# ---------------------------------------------------------------------------
# OnlyOffice conversion client
# ---------------------------------------------------------------------------
def _build_jwt_token(payload: dict) -> str | None:
secret = _get_jwt_secret()
if not secret:
return None
return jwt.encode(payload, secret, algorithm="HS256")
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
Returns the PNG bytes. Raises RuntimeError on failure.
"""
oo_url = _get_onlyoffice_url().rstrip("/")
convert_url = f"{oo_url}/ConvertService.ashx"
client = get_httpx_client()
# Start temporary HTTP server so OnlyOffice can fetch the file
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
"async": False,
"filetype": suffix,
"key": f"cista_{(await asyncio.to_thread(file_path.stat)).st_mtime_ns}",
"outputtype": "png",
"title": file_path.name,
"url": doc_url,
}
headers = {"Content-Type": "application/json"}
token = _build_jwt_token(payload)
if token:
# Conversion API expects JWT in request body when token checks are enabled.
payload["token"] = token
headers["Authorization"] = token
t_start = perf_counter()
response = await client.post(
convert_url,
content=json.dumps(payload).encode(),
headers=headers,
timeout=request_timeout,
)
response.raise_for_status()
body = response.content
t_end = perf_counter()
# Parse XML response
text = body.decode("utf-8", errors="replace")
if "<Error>" in text:
code = "unknown"
if "<Error>" in text and "</Error>" in text:
code = text.split("<Error>")[1].split("</Error>")[0]
raise RuntimeError(f"OnlyOffice conversion error: {code}")
if "<FileUrl>" not in text:
raise RuntimeError("OnlyOffice response did not contain FileUrl")
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
file_url = file_url.replace("&amp;", "&")
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
# Download converted PNG
png_response = await client.get(file_url, timeout=request_timeout)
png_response.raise_for_status()
return png_response.content
finally:
await asyncio.to_thread(httpd.shutdown)
+639 -218
View File
@@ -1,30 +1,35 @@
import asyncio import asyncio
import gc import contextlib
import io
import mimetypes import mimetypes
import struct
import sys
import threading import threading
import urllib.parse import urllib.parse
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import PurePosixPath from multiprocessing import cpu_count
from pathlib import Path, PurePosixPath
from time import perf_counter from time import perf_counter
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import av import httpx
import fitz # PyMuPDF import msgspec
import numpy as np from blake3 import blake3
import pillow_heif
from PIL import Image
from sanic import Blueprint, empty, raw, redirect from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
from cista import auth, config from cista import auth, config, onlyoffice, sharefs, watching
from cista.fileio import fuid
from cista.preview_worker import (
DOC_PREVIEW_SUFFIXES,
OFFICE_PREVIEW_SUFFIXES,
PreviewRequest,
PreviewResponse,
)
from cista.util.filename import sanitize from cista.util.filename import sanitize
pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
@@ -70,6 +75,374 @@ class PreviewCache:
# Global preview cache instance # Global preview cache instance
_preview_cache = PreviewCache(capacity=500) _preview_cache = PreviewCache(capacity=500)
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
WORKER_RESPAWN_DELAY = 1.0 # initial delay before retrying a failed worker spawn
WORKER_RESPAWN_DELAY_MAX = 30.0
_active_procs: set[asyncio.subprocess.Process] = set()
_preview_pool = None
_preview_pool_lock = asyncio.Lock()
AVIF_FAST_EFFORT = 0
WORKER_CHECKSUM_BYTES = 32
WORKER_MAX_JSON_BYTES = 1_000_000
class WorkerChecksumError(Exception):
"""Raised when worker response checksum does not match the packet."""
class WorkerProtocolError(Exception):
"""Raised when worker response packet is malformed."""
class _PreviewWorker:
def __init__(self, proc: asyncio.subprocess.Process):
self.proc = proc
async def request(
self,
filepath,
quality: int,
maxsize: int,
maxzoom: float,
data: bytes | None = None,
):
if self.proc.returncode is not None:
raise WorkerProtocolError("worker already exited")
if self.proc.stdin is None or self.proc.stdout is None:
raise WorkerProtocolError("worker streams not available")
meta = msgspec.json.encode(
PreviewRequest(
path=str(filepath),
quality=quality,
maxsize=maxsize,
maxzoom=maxzoom,
)
)
payload = data or b""
packet = struct.pack("<II", len(meta), len(payload)) + meta + payload
self.proc.stdin.write(packet)
await self.proc.stdin.drain()
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
header = await self.proc.stdout.readexactly(8)
json_size, data_size = struct.unpack("<II", header)
if json_size > WORKER_MAX_JSON_BYTES:
raise WorkerProtocolError(f"worker JSON too large: {json_size}")
meta_raw = await self.proc.stdout.readexactly(json_size)
payload = await self.proc.stdout.readexactly(data_size)
packet = header + meta_raw + payload
if blake3(packet).digest() != checksum:
raise WorkerChecksumError("worker checksum mismatch")
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
if not resp.ok:
raise PreviewError(
resp.error or "preview worker error",
stderr=resp.stderr,
backend=resp.backend,
)
return payload or None, resp
async def kill(self) -> None:
try:
if self.proc.returncode is None:
# Safe to hard-kill: the worker is stateless per request.
# proc.wait() must not be awaited unaided: if a pipe
# transport is flow-control paused (e.g. an undrained stderr
# pipe), asyncio may never resolve wait() even after SIGKILL,
# which would permanently wedge the calling dispatcher.
with contextlib.suppress(ProcessLookupError):
self.proc.kill()
try:
await asyncio.wait_for(self.proc.wait(), timeout=WORKER_KILL_GRACE)
except TimeoutError:
logger.error(
"Preview worker pid=%s not reaped within %ds of kill",
self.proc.pid,
int(WORKER_KILL_GRACE),
)
finally:
_active_procs.discard(self.proc)
class _PreviewWorkerPool:
def __init__(self, size: int):
self.size = size
self._idle: asyncio.Queue[_PreviewWorker] = asyncio.Queue()
self._pending: asyncio.PriorityQueue[tuple[int, int, asyncio.Future, tuple]] = (
asyncio.PriorityQueue()
)
self._workers: set[_PreviewWorker] = set()
self._dispatchers: list[asyncio.Task] = []
self._seq = 0
self._closed = False
async def _spawn_worker(self) -> _PreviewWorker:
# stderr is inherited, not piped: a piped stderr that nobody drains
# eventually fills its OS buffer, blocking the worker mid-request,
# and its flow-control-paused transport makes proc.wait() hang even
# after kill() — together this used to permanently wedge the pool.
# Inheriting sends worker diagnostics straight to the server log.
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"cista.preview_worker",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=None,
)
_active_procs.add(proc)
try:
ready = await asyncio.wait_for(proc.stdout.readexactly(1), timeout=30.0)
except TimeoutError as err:
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
raise WorkerProtocolError(
"preview worker failed to become ready"
" (worker stderr goes to the server log)"
) from err
except asyncio.IncompleteReadError as err:
raise WorkerProtocolError(
"preview worker exited before signalling readiness"
" (worker stderr goes to the server log)"
) from err
if ready != b"\x01":
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
return _PreviewWorker(proc)
async def _add_worker(self) -> None:
worker = await self._spawn_worker()
self._workers.add(worker)
await self._idle.put(worker)
async def _replace_worker(self, worker: _PreviewWorker) -> None:
self._workers.discard(worker)
try:
await worker.kill()
except Exception:
logger.exception("Failed to kill preview worker pid=%s", worker.proc.pid)
# Keep retrying until a replacement is up: a pool that silently
# shrinks degrades all preview traffic to timeouts.
delay = WORKER_RESPAWN_DELAY
while not self._closed:
try:
await self._add_worker()
except Exception:
logger.exception(
"Failed to replace preview worker (pool %d/%d); retrying in %ds",
len(self._workers),
self.size,
int(delay),
)
await asyncio.sleep(delay)
delay = min(delay * 2, WORKER_RESPAWN_DELAY_MAX)
else:
return
async def _dispatch_loop(self) -> None:
# Nothing may escape the loop body: a dispatcher that dies silently
# permanently shrinks pool capacity and degrades all preview
# traffic to timeouts.
while True:
try:
await self._dispatch_one()
except asyncio.CancelledError:
return
except Exception:
logger.exception("Preview dispatcher error; continuing")
async def _dispatch_one(self) -> None:
_priority, _seq, future, args = await self._pending.get()
if future.cancelled():
return
try:
worker = await asyncio.wait_for(self._idle.get(), timeout=PREVIEW_TIMEOUT)
except TimeoutError:
logger.warning(
"Preview worker unavailable (%ds) for %s",
int(PREVIEW_TIMEOUT),
args[0].name,
)
if not future.done():
future.set_exception(
PreviewTimeoutError(
args[0].name,
backend=_expected_preview_backend(args[0]),
)
)
return
filepath = args[0]
replace = False
try:
out, resp = await asyncio.wait_for(
worker.request(*args),
timeout=PREVIEW_TIMEOUT,
)
if not future.done():
future.set_result((out, resp))
except TimeoutError:
replace = True
logger.warning(
"Preview worker pid=%s timed out (%ds) on %s; replacing it",
worker.proc.pid,
int(PREVIEW_TIMEOUT),
filepath.name,
)
if not future.done():
future.set_exception(
PreviewTimeoutError(
filepath.name,
backend=_expected_preview_backend(filepath),
)
)
except WorkerChecksumError:
replace = True
logger.error(
"Preview checksum mismatch for %s (worker pid=%s); replacing it",
filepath.name,
worker.proc.pid,
)
if not future.done():
future.set_exception(
PreviewError(f"worker checksum mismatch for {filepath.name}")
)
except PreviewError as e:
if not future.done():
future.set_exception(e)
except (
WorkerProtocolError,
asyncio.IncompleteReadError,
BrokenPipeError,
ConnectionResetError,
OSError,
ValueError,
msgspec.DecodeError,
) as e:
replace = True
logger.warning(
"Preview worker pid=%s protocol failure for %s: %s",
worker.proc.pid,
filepath.name,
e,
)
if not future.done():
future.set_exception(
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
)
except Exception:
replace = True
logger.exception("Unexpected preview worker error for %s", filepath.name)
if not future.done():
future.set_exception(
PreviewError(f"unexpected worker error for {filepath.name}")
)
finally:
if replace:
await self._replace_worker(worker)
elif worker.proc.returncode is None:
await self._idle.put(worker)
else:
await self._replace_worker(worker)
async def start(self) -> None:
workers = await asyncio.gather(
*(self._spawn_worker() for _ in range(self.size))
)
for worker in workers:
self._workers.add(worker)
await self._idle.put(worker)
for _ in range(self.size):
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
async def run(
self,
filepath,
quality: int,
maxsize: int,
maxzoom: float,
data: bytes | None = None,
):
if self._closed:
raise PreviewError("preview worker pool closed")
loop = asyncio.get_running_loop()
future = loop.create_future()
self._seq += 1
await self._pending.put(
(
_preview_job_priority(filepath),
self._seq,
future,
(filepath, quality, maxsize, maxzoom, data),
)
)
return await future
async def close(self) -> None:
self._closed = True
for task in self._dispatchers:
task.cancel()
if self._dispatchers:
await asyncio.gather(*self._dispatchers, return_exceptions=True)
self._dispatchers.clear()
workers = list(self._workers)
self._workers.clear()
while not self._pending.empty():
try:
_priority, _seq, future, _args = self._pending.get_nowait()
except asyncio.QueueEmpty:
break
if not future.done():
future.set_exception(PreviewError("preview worker pool closed"))
while not self._idle.empty():
try:
self._idle.get_nowait()
except asyncio.QueueEmpty:
break
await asyncio.gather(
*(worker.kill() for worker in workers), return_exceptions=True
)
async def start_preview_workers() -> None:
"""Warm up persistent preview workers during server startup."""
global _preview_pool
if _preview_pool is not None:
return
async with _preview_pool_lock:
if _preview_pool is not None:
return
pool = _PreviewWorkerPool(PREVIEW_WORKERS)
await pool.start()
_preview_pool = pool
logger.info("Started %d persistent preview workers", PREVIEW_WORKERS)
async def shutdown_preview_workers() -> None:
"""Kill persistent preview workers (called during server shutdown)."""
global _preview_pool
async with _preview_pool_lock:
pool = _preview_pool
_preview_pool = None
if pool is not None:
await pool.close()
if not _active_procs:
return
for proc in list(_active_procs):
with contextlib.suppress(ProcessLookupError):
proc.kill()
await asyncio.gather(
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
)
_active_procs.clear()
@bp.on_request @bp.on_request
async def verify_preview(request): async def verify_preview(request):
@@ -77,16 +450,176 @@ async def verify_preview(request):
await auth.verify(request) await auth.verify(request)
# Map EXIF Orientation value to a corresponding PIL transpose class PreviewTimeoutError(Exception):
EXIF_ORI = { """Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180, def __init__(self, message: str, *, backend: str | None = None):
4: Image.Transpose.FLIP_TOP_BOTTOM, super().__init__(message)
5: Image.Transpose.TRANSPOSE, self.backend = backend
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90, class PreviewError(Exception):
} """Raised when the preview subprocess exits with a non-zero status."""
def __init__(
self,
message: str,
*,
stderr: str | None = None,
backend: str | None = None,
):
super().__init__(message)
self.stderr = stderr
self.backend = backend
# Max concurrent OnlyOffice conversion requests. OO has its own queue;
# we must not flood it. This is intentionally small.
OO_MAX_CONCURRENT = PREVIEW_WORKERS
class OOConversionManager:
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
self._semaphore = asyncio.Semaphore(max_concurrent)
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
self._tasks: set[asyncio.Task[None]] = set()
self._lock = asyncio.Lock()
async def convert(self, filepath: Path) -> bytes:
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
stat = await asyncio.to_thread(filepath.stat)
key = f"{filepath}:{stat.st_mtime_ns}"
async with self._lock:
if key in self._in_flight:
future = self._in_flight[key]
else:
future = asyncio.get_running_loop().create_future()
self._in_flight[key] = future
task = asyncio.create_task(self._do_convert(filepath, key, future))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return await future
async def _do_convert(
self, filepath: Path, key: str, future: asyncio.Future[bytes]
) -> None:
try:
async with self._semaphore:
png_bytes = await onlyoffice.convert_to_png_async(
filepath, request_timeout=5.0
)
except Exception as e:
if not future.done():
future.set_exception(e)
async with self._lock:
self._in_flight.pop(key, None)
else:
if not future.done():
future.set_result(png_bytes)
async with self._lock:
self._in_flight.pop(key, None)
_oo_manager: OOConversionManager | None = None
def get_oo_manager() -> OOConversionManager:
"""Return the singleton OOConversionManager."""
global _oo_manager
if _oo_manager is None:
_oo_manager = OOConversionManager(max_concurrent=OO_MAX_CONCURRENT)
return _oo_manager
async def _generate_office_preview(
filepath: Path, quality: int, maxsize: int, maxzoom: float
) -> tuple[bytes | None, PreviewResponse | None]:
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
manager = get_oo_manager()
t_oo_start = perf_counter()
png_bytes = await manager.convert(filepath)
t_oo_end = perf_counter()
img, resp = await _run_preview_process(
filepath, quality, maxsize, maxzoom, data=png_bytes
)
if resp is not None:
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
if resp.timings:
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
return img, resp
async def _run_preview_process(
filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
) -> tuple[bytes | None, PreviewResponse | None]:
"""Run preview request in a persistent worker process."""
await start_preview_workers()
if _preview_pool is None:
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
def _onlyoffice_error_short_text(detail: str) -> str:
if detail.startswith("OnlyOffice conversion error:"):
code = detail.rsplit(":", 1)[-1].strip()
return {
"-8": "onlyoffice jwt error",
"-4": "onlyoffice input error",
"-2": "onlyoffice timeout error",
"-1": "onlyoffice unknown error",
}.get(code, f"onlyoffice {code} error")
if "OnlyOffice response did not contain FileUrl" in detail:
return "onlyoffice no-fileurl error"
return "onlyoffice error"
def _preview_job_priority(path) -> int:
"""Return priority for preview job (lower=higher priority).
Priority order: images (0) < video (1) < PDF (2) < office (3) < unknown (4)
"""
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
return 2
if suffix in OFFICE_PREVIEW_SUFFIXES:
return 3
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("image/"):
return 0
if mime_type and mime_type.startswith("video/"):
return 1
return 4
def _expected_preview_backend(path: Path) -> str:
"""Best-effort backend label used for timeout/access logging."""
suffix = path.suffix.lower()
if suffix in OFFICE_PREVIEW_SUFFIXES:
return "onlyoffice"
if suffix in DOC_PREVIEW_SUFFIXES:
return "pdf"
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"):
return "video"
if mime_type and mime_type.startswith("image/"):
return "pyvips"
return "preview"
def is_previewable_path(path) -> bool:
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
return True
mime_type, _ = mimetypes.guess_type(path.name)
if not mime_type:
return False
return mime_type.startswith(("image/", "video/"))
@bp.get("/<path:path>") @bp.get("/<path:path>")
@@ -95,9 +628,24 @@ async def preview(req, path):
maxsize = int(req.args.get("px", 1024)) maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60)) quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path))) share_token = auth.request_share_token(req)
filepath = config.config.path / rel if share_token is not None:
stat = filepath.lstat() rel, _real_rel, filepath, is_root = sharefs.resolve_virtual_path(
share_token, path
)
if is_root:
raise NotFound from None
else:
rel = PurePosixPath(sanitize(unquote(path)))
filepath = config.config.path / rel
try:
stat = filepath.lstat()
except FileNotFoundError:
raise NotFound from None
if not is_previewable_path(filepath):
return empty(415)
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).hex()
@@ -112,214 +660,87 @@ async def preview(req, path):
logger.debug(f"Preview cache hit: {rel}") logger.debug(f"Preview cache hit: {rel}")
return raw(cached.body, headers=cached.headers) return raw(cached.body, headers=cached.headers)
if not filepath.is_file():
raise NotFound("File not found")
# Generate preview # Generate preview
img = await asyncio.get_event_loop().run_in_executor( try:
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom if filepath.suffix.lower() in OFFICE_PREVIEW_SUFFIXES:
) img, preview_resp = await asyncio.wait_for(
_generate_office_preview(filepath, quality, maxsize, maxzoom),
timeout=PREVIEW_TIMEOUT,
)
else:
img, preview_resp = await asyncio.wait_for(
_run_preview_process(filepath, quality, maxsize, maxzoom),
timeout=PREVIEW_TIMEOUT,
)
except TimeoutError:
req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
return empty(503)
except PreviewTimeoutError as e:
req.ctx.log_extra = (
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
)
return empty(503)
except httpx.HTTPStatusError:
req.ctx.log_extra = "onlyoffice N/A"
return empty(503)
except httpx.RequestError:
req.ctx.log_extra = "onlyoffice N/A"
return empty(503)
except RuntimeError as e:
detail = str(e)
if detail.startswith("OnlyOffice"):
req.ctx.log_extra = _onlyoffice_error_short_text(detail)
return empty(503)
raise
except PreviewError as e:
if e.backend:
req.ctx.log_extra = e.backend
detail = str(e)
if detail == "preview worker error" and e.stderr:
captured = e.stderr.strip()
if captured:
detail = captured.splitlines()[0]
logger.error("%s preview: %s", filepath, detail)
return empty(422)
except asyncio.CancelledError:
req.ctx.log_extra = "preview cancelled"
return empty(503)
except Exception:
logger.exception("Unhandled preview error for %s", filepath)
return empty(500)
if preview_resp and preview_resp.backend:
if preview_resp.timings:
timing_detail = "/".join(
str(round(value)) for value in preview_resp.timings
)
req.ctx.log_extra = f"{preview_resp.backend} {timing_detail}"
else:
req.ctx.log_extra = preview_resp.backend
if not img: if not img:
# Preview generation failed, redirect to the file itself # Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303) return redirect(f"/files/{path}", status=303)
# Store aspect ratio if the worker returned dimensions
if preview_resp and preview_resp.width and preview_resp.height:
ar = round(preview_resp.height / preview_resp.width, 2)
fuid_str = fuid(stat)
watching.notify_ar(fuid_str, ar)
# Build headers and cache the full response # Build headers and cache the full response
preview_mime = (
preview_resp.mime
if preview_resp is not None and preview_resp.mime is not None
else "image/avif"
)
savename = PurePosixPath(filepath.name).with_suffix(".avif") savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
"last-modified": format_date_time(stat.st_mtime), "last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable" "cache-control": "max-age=604800, immutable"
+ ("" if config.config.public else ", private"), + ("" if config.config.public else ", private"),
"content-type": "image/avif", "content-type": preview_mime,
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
} }
_preview_cache.set(etag, CachedPreview(headers=headers, body=img)) _preview_cache.set(etag, CachedPreview(headers=headers, body=img))
return raw(img, headers=headers) 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
+582
View File
@@ -0,0 +1,582 @@
"""Preview generation worker subprocess and synchronous preview engine.
Two modes are supported:
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
2) Long-lived mode: read framed requests from stdin and write framed responses.
Framed request format (stdin):
(uint32 json size)(uint32 data size)(json)(binary data)
Framed response format (stdout):
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
"""
import contextlib
import gc
import io
import logging
import mimetypes
import shlex
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
from time import perf_counter
import av
import msgspec
import numpy as np
import pymupdf
import pyvips
from blake3 import blake3
from cista import config
logger = logging.getLogger(__name__)
AVIF_FAST_EFFORT = 0
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
OFFICE_PREVIEW_SUFFIXES = {
".doc",
".dot",
".docx",
".docm",
".dotx",
".dotm",
".rtf",
".odt",
".ott",
".txt",
".md",
".mhtml",
".mht",
".html",
".htm",
".xml",
".wps",
".wri",
# Spreadsheets
".xls",
".xlsx",
".xlsm",
".xlsb",
".xltx",
".xltm",
".ods",
".ots",
".csv",
# Presentations
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
".odp",
".otp",
}
class PreviewRequest(msgspec.Struct, omit_defaults=True):
path: str
quality: int
maxsize: int
maxzoom: float
class PreviewResponse(msgspec.Struct, omit_defaults=True):
ok: bool
mime: str | None = None
backend: str | None = None
timings: list[float] | None = None
error: str | None = None
stderr: str | None = None
width: int | None = None
height: int | None = None
_enc = msgspec.json.Encoder()
_dec_req = msgspec.json.Decoder(PreviewRequest)
def _read_exactly(f, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = f.read(n - len(buf))
if not chunk:
raise EOFError
buf += chunk
return buf
def _read_request() -> tuple[PreviewRequest, bytes] | None:
try:
header = _read_exactly(sys.stdin.buffer, 8)
except EOFError:
return None
json_size, data_size = struct.unpack("<II", header)
meta_raw = _read_exactly(sys.stdin.buffer, json_size)
data = b""
if data_size:
data = _read_exactly(sys.stdin.buffer, data_size)
req = _dec_req.decode(meta_raw)
return req, data
# Raw stdout buffer reserved for the binary protocol once main() redirects
# Python-level stdout to stderr. None means "use sys.stdout.buffer as-is"
# (CLI single-shot mode, where real stdout is wanted).
_protocol_out = None
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
out = _protocol_out if _protocol_out is not None else sys.stdout.buffer
meta_bytes = _enc.encode(resp)
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
checksum = blake3(packet).digest()
out.write(checksum)
out.write(packet)
out.flush()
def dispatch(path, quality, maxsize, maxzoom, data=None):
backend = "unknown"
try:
if data:
backend = "pyvips"
return process_image_buffer(
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
)
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
backend = "pdf"
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"):
backend = "video"
return process_video(path, quality=quality, maxsize=maxsize)
if mime_type and mime_type.startswith("image/"):
backend = "pyvips"
return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e:
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
except Exception as e:
logger.exception("Preview dispatch failed for %s", path)
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
def process_image(path, *, maxsize, quality):
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
"""Probe image dimensions.
pyvips can read the header of most formats (including HEIC) without
fully decoding the image.
"""
try:
img = pyvips.Image.new_from_file(str(path))
img = img.autorot()
except pyvips.error.Error:
return None
else:
return img.width, img.height
def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
"""Convert any image to AVIF using ffmpeg CLI.
ffmpeg handles HEIC tile assembly, EXIF rotation, HDR metadata and
ICC profile embedding automatically.
"""
dims = _get_image_dimensions(path)
crf = int(63 * (1 - quality / 100) ** 2)
with tempfile.NamedTemporaryFile(suffix=".avif", delete=False) as tmp_f:
tmp_path = tmp_f.name
cmd = [
"ffmpeg",
"-y",
"-i",
str(path),
"-frames:v",
"1",
"-c:v",
"av1",
"-crf",
str(crf),
"-cpu-used",
"8",
tmp_path,
]
if dims is not None:
w, h = dims
if max(w, h) > maxsize:
scale = min(maxsize / w, maxsize / h)
new_w = int(w * scale)
new_h = int(h * scale)
# insert -s <wxh> right after the input file
cmd.insert(4, "-s")
cmd.insert(5, f"{new_w}x{new_h}")
try:
try:
# stdin=DEVNULL is critical: ffmpeg must not inherit the worker's
# stdin, which carries the framed request protocol. An inherited
# stdin lets ffmpeg eat protocol bytes and, if the worker is
# killed mid-conversion, keeps the orphaned ffmpeg holding the
# pipe open so the parent's proc.wait() hangs forever.
subprocess.run( # noqa: S603
cmd,
capture_output=True,
check=True,
shell=False,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as e:
shell_cmd = shlex.join(cmd)
stderr = (e.stderr or b"").decode(errors="replace").strip()
if stderr:
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
) from e
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
) from e
with Path(tmp_path).open("rb") as f:
return f.read()
finally:
Path(tmp_path).unlink(missing_ok=True)
def process_image_pyvips(path, *, maxsize, quality):
t_start = perf_counter()
suffix = path.suffix.lower()
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
# skip pyvips entirely.
if suffix in (".heic", ".heif"):
heic_dims = _get_image_dimensions(path)
width, height = heic_dims or (None, None)
ret = _image_via_ffmpeg(path, maxsize, quality)
t_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend="ffmpeg",
timings=[round((t_end - t_start) * 1000, 1)],
width=width,
height=height,
)
# Other image formats: pyvips first, ffmpeg fallback.
load_opts = {"access": "sequential"}
orig_w = orig_h = None
try:
img = pyvips.Image.new_from_file(str(path), **load_opts)
img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
ret = img.write_to_buffer(
".avif",
Q=quality,
effort=AVIF_FAST_EFFORT,
strip=True,
)
backend = "pyvips"
except pyvips.error.Error:
orig_w, orig_h = None, None
ret = _image_via_ffmpeg(path, maxsize, quality)
backend = "ffmpeg"
t_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend=backend,
timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
)
def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
_ = maxzoom
t_start = perf_counter()
img = pyvips.Image.new_from_buffer(data, "")
img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
ret = img.write_to_buffer(
".avif",
Q=quality,
effort=AVIF_FAST_EFFORT,
strip=True,
)
t_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend="pyvips",
timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
)
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
t_load_start = perf_counter()
with pymupdf.open(path) as pdf:
page = pdf.load_page(page_number)
w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = pymupdf.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
t_load_end = perf_counter()
t_save_start = perf_counter()
img = pyvips.Image.new_from_memory(
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
)
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
backend = "pdf+pyvips"
t_save_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend=backend,
timings=[
round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1),
],
width=round(w),
height=round(h),
)
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),
options={
"analyzeduration": "1000000", # 1 second (in microseconds)
"fflags": "fastseek",
},
) 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
# Capture display dimensions before resize (accounting for rotation)
disp_w = frame.width
disp_h = frame.height
if frame.rotation in (90, 270):
disp_w, disp_h = disp_h, disp_w
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:
logger.exception("Error rotating video frame by 180°")
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:
logger.exception(
"Error rotating video frame by %s°", frame.rotation
)
# libsvtav1 rejects full-range JPEG-style YUV pixel formats such as
# yuvj420p, so normalize them before opening the encoder.
if frame.format.name.startswith("yuvj"):
frame = frame.reformat(format="yuv420p")
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",
},
)
if not isinstance(ostream, av.VideoStream):
raise TypeError("failed to initialize AV1 video stream")
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 result before cleanup
ret = imgdata.getvalue()
resp = PreviewResponse(
ok=True,
mime="image/avif",
backend="video",
timings=[
round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1),
],
width=disp_w,
height=disp_h,
)
del imgdata, istream, ostream, icc, occ, frame
gc.collect()
return ret, resp
def _run_once() -> None:
if len(sys.argv) != 5:
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
sys.exit(1)
path = Path(sys.argv[1])
quality = int(sys.argv[2])
maxsize = int(sys.argv[3])
maxzoom = float(sys.argv[4])
result, _ = dispatch(path, quality, maxsize, maxzoom)
if result:
sys.stdout.buffer.write(result)
sys.stdout.buffer.flush()
def _run_loop() -> None:
while True:
result = _read_request()
if result is None:
return
req, data = result
stderr_capture = io.StringIO()
handler = logging.StreamHandler(stderr_capture)
root_logger = logging.getLogger()
root_logger.addHandler(handler)
try:
with contextlib.redirect_stderr(stderr_capture):
result, resp = dispatch(
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
)
if not resp.ok:
captured = stderr_capture.getvalue().strip()
if captured:
resp = PreviewResponse(
ok=False,
backend=resp.backend,
error=resp.error,
stderr=captured,
)
_write_response(resp, result or b"")
except Exception as e:
logger.exception("Preview worker error for %s", req.path)
captured = stderr_capture.getvalue().strip()
_write_response(
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
)
finally:
root_logger.removeHandler(handler)
handler.close()
def main() -> None:
# Configure all log output to stderr before any imports that may emit logs.
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
try:
config.load_config()
logger.info("preview-worker config=%s", config.conffile)
except Exception:
logger.exception("preview-worker failed to load config at startup")
if len(sys.argv) > 1:
_run_once()
return
# The command channel is a binary protocol on fd 1. Anything printed to
# stdout by Python code (e.g. a library emitting a warning via print())
# would corrupt the protocol, so redirect Python-level stdout to stderr
# (the server log) and keep the raw buffer solely for protocol traffic.
global _protocol_out
_protocol_out = sys.stdout.buffer
sys.stdout = sys.stderr
# Eagerly import heavy modules before signalling readiness so the parent
# does not hand us a request while we are still initialising.
_protocol_out.write(b"\x01")
_protocol_out.flush()
_run_loop()
if __name__ == "__main__":
main()
+2 -129
View File
@@ -1,136 +1,8 @@
from __future__ import annotations from __future__ import annotations
import shutil
from pathlib import PurePosixPath
from typing import Any from typing import Any
import msgspec import msgspec
from sanic import BadRequest
from cista import config
from cista.util import filename
## Control commands
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self):
raise NotImplementedError
def affected_paths(self) -> list[str]:
"""Return list of paths affected by this operation for change notification."""
return []
class MkDir(ControlBase):
path: str
def __call__(self):
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
to: str
def __call__(self):
to = filename.sanitize(self.to)
if "/" in to:
raise BadRequest("Rename 'to' name should only contain filename, not path")
path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to))
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]
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
for p in sel:
if p.is_dir():
shutil.rmtree(p)
else:
p.unlink()
def affected_paths(self) -> list[str]:
return [filename.sanitize(p) for p in self.sel]
class Mv(ControlBase):
sel: list[str]
dst: str
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
dst = root / filename.sanitize(self.dst)
if not dst.is_dir():
raise BadRequest("The destination must be a directory")
for p in sel:
shutil.move(p, dst)
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]
dst: str
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
dst = root / filename.sanitize(self.dst)
if not dst.is_dir():
raise BadRequest("The destination must be a directory")
for p in sel:
if p.is_dir():
# Note: copies as dst rather than in dst unless name is appended.
shutil.copytree(
p,
dst / p.name,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
)
else:
shutil.copy2(p, dst)
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
## File uploads and downloads
class FileRange(msgspec.Struct):
name: str
size: int
start: int
end: int
class StatusMsg(msgspec.Struct):
status: str
req: FileRange
class ErrorMsg(msgspec.Struct): class ErrorMsg(msgspec.Struct):
@@ -140,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
## Directory listings ## Directory listings
class FileEntry(msgspec.Struct, array_like=True, frozen=True): class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
level: int level: int
name: str name: str
key: str key: str
@@ -148,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
size: int size: int
allocated: int allocated: int
isfile: int isfile: int
ar: float | None = None
def __str__(self): def __str__(self):
return self.key or "FileEntry()" return self.key or "FileEntry()"
+320
View File
@@ -0,0 +1,320 @@
"""Custom access logging middleware for Sanic."""
import logging
import os
import sys
import unicodedata
from ipaddress import IPv6Address
from sanic.log import LOGGING_CONFIG_DEFAULTS
logger = logging.getLogger("cista.access")
class ReentrantSafeStreamHandler(logging.StreamHandler):
"""Stream handler that degrades gracefully on signal-time reentrant writes.
Python's buffered text streams are not reentrant. If a signal handler logs
while another log write is in progress, StreamHandler.emit can raise:
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
Instead of letting logging emit a long "--- Logging error ---" traceback,
we fall back to a best-effort os.write to the same file descriptor.
"""
def emit(self, record: logging.LogRecord) -> None:
msg = ""
try:
msg = self.format(record)
stream = self.stream
stream.write(msg + self.terminator)
self.flush()
except RuntimeError as exc:
if "reentrant call inside" not in str(exc):
self.handleError(record)
return
stream = self.stream
fd = stream.fileno()
encoding = getattr(stream, "encoding", None) or "utf-8"
data = (msg + self.terminator).encode(encoding, errors="replace")
os.write(fd, data)
except RecursionError:
raise
except Exception:
self.handleError(record)
_RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (light grey)
_TIMING = "\033[38;5;242m" # timing (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
def format_ipv6_network(ip: str) -> str:
"""Format IPv6 address to show only network part (first 64 bits)."""
try:
ip = ip.strip("[]")
if "%" in ip:
ip = ip.split("%")[0]
addr = IPv6Address(ip)
if addr.is_loopback:
return "::1"
if addr.is_unspecified:
return "::"
if addr.ipv4_mapped:
return str(addr.ipv4_mapped)
if addr.is_link_local:
return str(addr)
network_int = int(addr) >> 64
groups = []
for _ in range(4):
groups.insert(0, format(network_int & 0xFFFF, "x"))
network_int >>= 16
result = ":".join(groups) + "::"
return str(IPv6Address(result + "0")).removesuffix("::")
except Exception:
return ip
def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
stripped = ip.strip("[]")
if ":" in stripped:
return format_ipv6_network(stripped)
return stripped
def status_color(status: int) -> str:
if status < 200:
return _STATUS_INFO
if status < 300:
return _STATUS_OK
if status < 400:
return _STATUS_REDIRECT
if status < 500:
return _STATUS_CLIENT_ERR
return _STATUS_SERVER_ERR
def method_color(method: str) -> str:
if method in ("GET", "HEAD", "OPTIONS"):
return _METHOD_READ
return _METHOD_WRITE
def format_duration_ms(duration_ms: float) -> str:
rounded_ms = round(duration_ms)
if rounded_ms < 2000:
return f"{rounded_ms}ms"
total_s = round(duration_ms / 1000)
if total_s < 60:
return f"{total_s}s"
if total_s <= 3600:
minutes, seconds = divmod(total_s, 60)
return f"{minutes}m{seconds}s"
hours, remainder = divmod(total_s, 3600)
minutes = round(remainder / 60)
if minutes == 60:
hours += 1
minutes = 0
return f"{hours}h{minutes}m"
def _display_width(text: str) -> int:
return sum(
1 + (unicodedata.east_asian_width(c) in "FW")
for c in text
if unicodedata.category(c) != "Mn"
)
def _format_left(label: str) -> str:
return label[:19].ljust(19)
def _format_method_label(label: str, *, color: str | None = None) -> str:
color_value = _METHOD_WRITE if color is None else color
padding = max(0, 7 - _display_width(label))
return f"{color_value}{label}{' ' * padding}{_RESET}"
def format_access_log(
client: str,
status: int,
method: str,
host: str,
path: str,
*,
duration_ms: float,
extra: str | None = None,
) -> str:
ip = _format_left(format_client_ip(client))
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
method_str = _format_method_label(method, color=method_color(method))
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return (
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
)
_ws_counter = 1
def _next_ws_id() -> int:
global _ws_counter
ws_id = _ws_counter
_ws_counter += 1
return ws_id
def _format_ws_id(ws_id: int, *, bright: bool = False) -> str:
value = str(ws_id) if ws_id >= 100 else f"{ws_id:02d}"
color = _WS_OPEN if bright else _WS_CLOSE
return f"{color}{value.rjust(3)}{_RESET}"
def log_ws_open(request, extra: str | None = None) -> int:
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
ws_id = _next_ws_id()
client = request.client_ip or "-"
host = request.host or "-"
path = request.path
origin = request.headers.get("origin")
ip = _format_left(format_client_ip(client))
id_str = _format_ws_id(ws_id, bright=True)
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
method_str = _format_method_label("🔌", color=_WS_OPEN)
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info(
"%s %s %s %s%s%s",
ip,
id_str,
method_str,
host_str,
path_str,
origin_str + extra_str,
)
return ws_id
WS_CLOSE_CODES = {
1000: "ok",
1001: "going away",
1002: "protocol error",
1003: "unsupported",
1005: "no status",
1006: "abnormal",
1007: "invalid data",
1008: "policy violation",
1009: "too large",
1010: "extension required",
1011: "server error",
1012: "restarting",
1013: "try again",
1014: "bad gateway",
1015: "tls error",
}
def log_ws_close(
ws_id: int, close_code: int | None, duration: float, extra: str | None = None
) -> None:
"""Log WebSocket connection close with duration and status."""
id_str = _format_ws_id(ws_id)
timing = format_duration_ms(duration * 1000)
if close_code is None:
code = "----"
status = "unknown"
else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
method_str = _format_method_label("closed", color=_TIMING)
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info(
"%s %s %s %s %s%s",
" " * 19,
id_str,
method_str,
status_str,
timing_str,
extra_str,
)
def configure_access_logging() -> None:
"""Configure the cista.access logger to output to stderr."""
handler = ReentrantSafeStreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_LEVEL_EMOJI = {
logging.DEBUG: "🔍",
logging.INFO: "", # noqa: RUF001
logging.WARNING: "⚠️",
logging.ERROR: "🛑",
logging.CRITICAL: "🛑",
}
def _format_level_prefix(levelno: int) -> str:
emoji = _LEVEL_EMOJI.get(levelno, "▪️")
prefix = f"{emoji} "
return prefix + (" " * max(0, 3 - _display_width(prefix)))
class _EmojiFormatter(logging.Formatter):
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
def format(self, record: logging.LogRecord) -> str:
return _format_level_prefix(record.levelno) + record.getMessage()
def configure_main_logging() -> None:
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
call Sanic makes during serve_single() / serve().
"""
for handler_name in ("console", "error_console", "access_console"):
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
"cista.sanic_logging.ReentrantSafeStreamHandler"
)
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
"class": "cista.sanic_logging._EmojiFormatter",
}
# Also reformat any handlers already attached (covers the initial Sanic() call)
for name in ("sanic.root", "sanic.error", "sanic.server", "sanic.websockets"):
for handler in logging.getLogger(name).handlers:
handler.setFormatter(_EmojiFormatter())
+14 -8
View File
@@ -4,15 +4,20 @@ from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic from sanic import Sanic
from sanic.worker.loader import AppLoader
from cista import config, server80 from cista import config, server80
from cista.app import app
def load_app() -> Sanic:
"""Return the app instance for spawned Sanic worker/reloader processes."""
return app
def run(*, dev=False): def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app _url, opts = parse_listen(config.config.listen)
url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
confdir = config.conffile.parent confdir = config.conffile.parent
@@ -21,24 +26,25 @@ def run(*, dev=False):
server80.app.prepare(port=80, motd=False) server80.app.prepare(port=80, motd=False)
domain = opts["host"] domain = opts["host"]
check_cert(confdir / domain, domain) check_cert(confdir / domain, domain)
opts["ssl"] = str(confdir / domain) # type: ignore opts["ssl"] = str(confdir / domain) # type: ignore[assignment]
app.prepare( app.prepare(
**opts, **opts,
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
access_log=True, access_log=False,
) # type: ignore ) # type: ignore[call-arg]
if dev: if dev:
Sanic.serve() Sanic.serve(app_loader=AppLoader(factory=load_app))
else: else:
Sanic.serve_single() Sanic.serve_single()
def check_cert(certdir, domain): def check_cert(certdir, domain):
_ = domain
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists(): if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
return return
# TODO: Use certbot to fetch a cert # Certificate provisioning is external; files must exist before startup.
raise ValueError( raise ValueError(
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}", f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
) )
+2
View File
@@ -6,6 +6,7 @@ app = Sanic("server80")
# Send all HTTP users to HTTPS # Send all HTTP users to HTTPS
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported) @app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
def redirect_everything_else(request, exception): def redirect_everything_else(request, exception):
_ = exception
server, path = request.server_name, request.path server, path = request.server_name, request.path
if server and path.startswith("/"): if server and path.startswith("/"):
return response.redirect(f"https://{server}{path}", status=308) return response.redirect(f"https://{server}{path}", status=308)
@@ -15,6 +16,7 @@ def redirect_everything_else(request, exception):
# ACME challenge for LetsEncrypt # ACME challenge for LetsEncrypt
@app.get("/.well-known/acme-challenge/<challenge>") @app.get("/.well-known/acme-challenge/<challenge>")
async def letsencrypt(request, challenge): async def letsencrypt(request, challenge):
_ = request
try: try:
return response.text(acme_challenges[challenge]) return response.text(acme_challenges[challenge])
except KeyError: except KeyError:
+46 -26
View File
@@ -1,43 +1,63 @@
import secrets
from time import time from time import time
import jwt # In-memory session store: token -> {"username": str, "exp": int}
_sessions: dict[str, dict] = {}
from cista.config import derived_secret
def session_secret():
return derived_secret("session")
SESSION_COOKIE_NAME = "cista"
max_age = 365 * 86400 # Seconds since last login max_age = 365 * 86400 # Seconds since last login
def _token() -> str:
return secrets.token_urlsafe(8)
def _purge_expired() -> None:
now = time()
expired = [t for t, s in _sessions.items() if s["exp"] <= now]
for t in expired:
del _sessions[t]
def get(request): def get(request):
try: token = request.cookies.get(SESSION_COOKIE_NAME)
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) if token is None:
except Exception: return None
return False if "s" in request.cookies else None s = _sessions.get(token)
if s is None:
return False # Cookie present but session not found / expired
if s["exp"] <= time():
del _sessions[token]
return False
return s
def create(res, username, **kwargs): def create(request, res, username, **kwargs):
data = { _purge_expired()
"exp": int(time()) + max_age, token = _token()
"username": username, put(token, username, **kwargs)
**kwargs, secure = request.scheme == "https"
} res.cookies.add_cookie(
s = jwt.encode(data, session_secret()) SESSION_COOKIE_NAME,
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) token,
httponly=True,
max_age=max_age,
secure=secure,
host_prefix=secure,
)
def update(res, s, **kwargs): def delete(request, res):
s.update(kwargs) token = request.cookies.get(SESSION_COOKIE_NAME)
s = jwt.encode(s, session_secret()) if token is not None:
max_age = max(1, s["exp"] - int(time())) # type: ignore _sessions.pop(token, None)
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) secure = request.scheme == "https"
res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
def delete(res): def put(token: str, username: str, **kwargs) -> None:
res.cookies.delete_cookie("s") _sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
def flash(res, message: str | None): def flash(res, message: str | None):
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG
from time import time
from typing import NamedTuple
from natsort import humansorted
from sanic.exceptions import BadRequest, NotFound
from cista import config, watching
from cista.fileio import fuid
from cista.protocol import FileEntry
from cista.util.filename import sanitize
class ShareRootEntry(NamedTuple):
alias: str
real_rel: PurePosixPath
def _token_is_share(token: config.Token) -> bool:
return token.kind == "share" and bool(token.share_paths)
def is_share_token(token: config.Token | None) -> bool:
return bool(token and _token_is_share(token))
def build_share_roots(token: config.Token) -> list[ShareRootEntry]:
if not _token_is_share(token):
return []
base = config.config.path.resolve()
roots: list[ShareRootEntry] = []
used_aliases: set[str] = set()
for raw_path in token.share_paths:
try:
clean = sanitize(raw_path)
except ValueError:
continue
if not clean:
continue
rel = PurePosixPath(clean)
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base) or not resolved.exists():
continue
display = rel.name or config.config.path.name
alias = display
suffix = 2
while alias in used_aliases:
alias = f"{display} ({suffix})"
suffix += 1
used_aliases.add(alias)
roots.append(ShareRootEntry(alias=alias, real_rel=rel))
return roots
def resolve_virtual_path(
token: config.Token,
raw_path: str,
) -> tuple[PurePosixPath, PurePosixPath, Path, bool]:
"""Resolve a share-virtual path to real path.
Returns (virtual_rel, real_rel, real_abs, is_virtual_root).
"""
base = config.config.path.resolve()
if raw_path.strip("/") == "":
return PurePosixPath(), PurePosixPath(), base, True
try:
clean = sanitize(raw_path)
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
if not clean:
return PurePosixPath(), PurePosixPath(), base, True
virtual_rel = PurePosixPath(clean)
roots = build_share_roots(token)
if not roots:
raise NotFound("Share token has no visible files")
root_by_alias = {r.alias: r.real_rel for r in roots}
first = virtual_rel.parts[0]
real_root = root_by_alias.get(first)
if real_root is None:
raise NotFound(f"Not found: {raw_path}")
rest = virtual_rel.parts[1:]
real_rel = real_root.joinpath(*rest) if rest else real_root
resolved = (base / real_rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
return virtual_rel, real_rel, resolved, False
def real_to_virtual_aliases(token: config.Token) -> dict[PurePosixPath, str]:
return {entry.real_rel: entry.alias for entry in build_share_roots(token)}
def _walk_virtual_entry(path: Path, name: str, level: int) -> list[FileEntry]:
st = path.lstat()
is_dir = S_ISDIR(st.st_mode)
is_file = S_ISREG(st.st_mode)
if not is_dir and not is_file:
return []
if is_file:
try:
allocated = watching.get_allocated_size(path, st)
except Exception:
allocated = st.st_size
return [
FileEntry(
level=level,
name=name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size,
allocated=allocated,
isfile=1,
)
]
children: list[tuple[int, str, object]] = []
for child in path.iterdir():
if child.name.startswith("."):
continue
try:
cst = child.lstat()
except FileNotFoundError:
continue
c_is_file = S_ISREG(cst.st_mode)
c_is_dir = S_ISDIR(cst.st_mode)
if not c_is_file and not c_is_dir:
continue
children.append((int(c_is_file), child.name, cst))
entries: list[FileEntry] = []
agg_mtime = int(st.st_mtime)
agg_size = 0
agg_alloc = 0
for _, child_name, _ in humansorted(children):
child_path = path / child_name
child_entries = _walk_virtual_entry(child_path, child_name, level + 1)
if not child_entries:
continue
head = child_entries[0]
agg_mtime = max(agg_mtime, head.mtime)
agg_size += head.size
agg_alloc += head.allocated
entries.extend(child_entries)
head = FileEntry(
level=level,
name=name,
key=fuid(st),
mtime=agg_mtime,
size=agg_size,
allocated=agg_alloc,
isfile=0,
)
return [head, *entries]
def build_virtual_root(token: config.Token) -> list[FileEntry]:
roots = build_share_roots(token)
now = int(time())
root_key = config.derived_secret("share-root", token.key or "", token.created).hex()
entries: list[FileEntry] = []
total_size = 0
total_alloc = 0
root_mtime = 0
base = config.config.path.resolve()
for entry in roots:
real_abs = (base / entry.real_rel).resolve()
if not real_abs.is_relative_to(base) or not real_abs.exists():
continue
try:
subtree = _walk_virtual_entry(real_abs, entry.alias, 1)
except OSError:
continue
if not subtree:
continue
head = subtree[0]
total_size += head.size
total_alloc += head.allocated
root_mtime = max(root_mtime, head.mtime)
entries.extend(subtree)
root = FileEntry(
level=0,
name="",
key=root_key,
mtime=root_mtime or now,
size=total_size,
allocated=total_alloc,
isfile=0,
)
return [root, *entries]
def key_paths_for_token(
token: config.Token, wanted: set[str]
) -> dict[str, PurePosixPath]:
ret: dict[str, PurePosixPath] = {}
loc = PurePosixPath()
root = build_virtual_root(token)
for f in root:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if f.key in wanted and f.key not in ret:
ret[f.key] = loc
if len(ret) == len(wanted):
break
return ret
def resolve_virtual_rel_to_real(token: config.Token, rel: PurePosixPath) -> Path:
_vrel, _rrel, real_abs, is_root = resolve_virtual_path(token, rel.as_posix())
if is_root:
raise BadRequest("Virtual root is not a writable filesystem path")
return real_abs
+86 -18
View File
@@ -62,12 +62,18 @@ async def close_client():
_client = None _client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None: async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
"""Validate an SSO request against the auth backend. """Validate an SSO request against the auth backend.
Args: Args:
request: The Sanic request object request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin) perm: Permission to validate (default: cista:login, privileged also cista:admin)
renew: Whether to allow the auth backend to renew the session cookie.
Use ``False`` for WebSocket validation where Set-Cookie cannot be
forwarded to the client; this makes the request read-only and avoids
resetting the backend renewal timeout.
Returns: Returns:
User info dict if valid, None if validation fails with auth required response User info dict if valid, None if validation fails with auth required response
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
headers["cookie"] = request.headers["cookie"] headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers: if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"] headers["authorization"] = request.headers["authorization"]
if "user-agent" in request.headers:
headers["user-agent"] = request.headers["user-agent"]
headers["accept"] = "application/json" headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}" url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
if not renew:
url += "&renew=0"
try: try:
response = await client.post( response = await client.post(
@@ -107,10 +117,11 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
request.ctx.sso_user = data request.ctx.sso_user = data
if "set-cookie" in response.headers: if "set-cookie" in response.headers:
request.ctx.sso_cookies = response.headers.get_list("set-cookie") request.ctx.sso_cookies = response.headers.get_list("set-cookie")
return data
except Exception: except Exception:
request.ctx.sso_user = {} request.ctx.sso_user = {}
return {} return {}
else:
return data
try: try:
error_data = response.json() error_data = response.json()
@@ -126,22 +137,21 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
context=error_data, context=error_data,
quiet=True, quiet=True,
) )
elif response.status_code == 403: if response.status_code == 403:
raise Forbidden( raise Forbidden(
error_data.get("detail", "Access denied"), error_data.get("detail", "Access denied"),
context=error_data, context=error_data,
quiet=True, quiet=True,
) )
else: detail = error_data.get("detail", "")
detail = error_data.get("detail", "") logger.warning(
logger.warning( f"SSO validation {url} returned {response.status_code}: {detail}"
f"SSO validation {url} returned {response.status_code}: {detail}" )
) raise Forbidden(
raise Forbidden( detail or "Authentication error",
detail or "Authentication error", context=error_data,
context=error_data, quiet=True,
quiet=True, )
)
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"SSO validation {url} network error: {e}") logger.error(f"SSO validation {url} network error: {e}")
@@ -149,8 +159,64 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
"Authentication service unavailable", "Authentication service unavailable",
status_code=502, status_code=502,
quiet=True, quiet=True,
) from e
async def check_permissions(user_id: str, perm: str) -> dict:
"""Check if a Paskia user has the given permission.
Calls /auth/api/check?user=<UUID>&perm=<perm> — no session or cookies needed.
Args:
user_id: The Paskia user UUID
perm: Permission to check (e.g. cista:login or cista:admin)
Returns:
User info dict if permission is granted
Raises:
Forbidden: If permission is denied or check fails
SanicException: If the auth service is unreachable
"""
if not paskia_enabled():
raise ValueError("Paskia not enabled")
client = await get_client()
url = f"{PASKIA_BACKEND_URL}/auth/api/check"
try:
response = await client.get(
url,
params={"user": user_id, "perm": perm},
headers={"accept": "application/json"},
) )
if response.status_code == 200:
return response.json()
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text or "Permission check failed"}
if response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
quiet=True,
)
raise Forbidden(
error_data.get("detail", "Permission check failed"),
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"Permission check {url} network error: {e}")
raise SanicException(
"Authentication service unavailable",
status_code=502,
quiet=True,
) from e
async def proxy_auth_request(request): async def proxy_auth_request(request):
"""Proxy a request to the auth backend. """Proxy a request to the auth backend.
@@ -202,7 +268,7 @@ async def proxy_auth_request(request):
method=request.method, method=request.method,
url=url, url=url,
headers=headers, headers=headers,
content=request.body if request.body else None, content=request.body or None,
) as response: ) as response:
raw_content = b"".join([chunk async for chunk in response.aiter_raw()]) raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
@@ -267,15 +333,15 @@ async def proxy_auth_websocket(request, ws):
try: try:
async for message in ws: async for message in ws:
await backend_ws.send(message) await backend_ws.send(message)
except Exception: except Exception as e:
pass logger.debug("WebSocket forward_to_backend ended: %s", e)
async def forward_to_client(): async def forward_to_client():
try: try:
async for message in backend_ws: async for message in backend_ws:
await ws.send(message) await ws.send(message)
except Exception: except Exception as e:
pass logger.debug("WebSocket forward_to_client ended: %s", e)
await asyncio.gather( await asyncio.gather(
forward_to_backend(), forward_to_backend(),
@@ -293,6 +359,7 @@ bp = Blueprint("sso", url_prefix="/auth")
@bp.websocket("/ws/<path:path>") @bp.websocket("/ws/<path:path>")
async def auth_websocket_proxy(request, ws, path=""): async def auth_websocket_proxy(request, ws, path=""):
"""Proxy WebSocket connections to the auth backend.""" """Proxy WebSocket connections to the auth backend."""
_ = path
await proxy_auth_websocket(request, ws) await proxy_auth_websocket(request, ws)
@@ -307,6 +374,7 @@ async def auth_websocket_proxy_root(request, ws):
) )
async def auth_proxy(request, path=""): async def auth_proxy(request, path=""):
"""Proxy all auth requests to the auth backend.""" """Proxy all auth requests to the auth backend."""
_ = path
return await proxy_auth_request(request) return await proxy_auth_request(request)
+33 -1
View File
@@ -1,6 +1,8 @@
import time
from functools import wraps from functools import wraps
import msgspec import msgspec
import websockets.exceptions
from sanic import errorpages from sanic import errorpages
from sanic.exceptions import SanicException from sanic.exceptions import SanicException
from sanic.log import logger from sanic.log import logger
@@ -8,6 +10,7 @@ from sanic.response import raw, redirect
from cista import auth from cista import auth
from cista.protocol import ErrorMsg from cista.protocol import ErrorMsg
from cista.sanic_logging import log_ws_close, log_ws_open
def asend(ws, msg): def asend(ws, msg):
@@ -22,11 +25,13 @@ def jres(data, **kwargs):
async def handle_sanic_exception(request, e): async def handle_sanic_exception(request, e):
context, code = {}, 500 context, code = {}, 500
headers = None
message = str(e) message = str(e)
if isinstance(e, SanicException): if isinstance(e, SanicException):
context = e.context or {} context = e.context or {}
code = e.status_code code = e.status_code
if not message or not request.app.debug and code == 500: headers = getattr(e, "headers", None)
if not message or (not request.app.debug and code == 500):
message = "Internal Server Error" message = "Internal Server Error"
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
if code == 500: if code == 500:
@@ -39,6 +44,7 @@ async def handle_sanic_exception(request, e):
return jres( return jres(
response_data, response_data,
status=code, status=code,
headers=headers,
) )
# Redirections flash the error message via cookies # Redirections flash the error message via cookies
if "redirect" in context: if "redirect" in context:
@@ -54,9 +60,20 @@ def websocket_wrapper(handler):
@wraps(handler) @wraps(handler)
async def wrapper(request, ws, *args, **kwargs): async def wrapper(request, ws, *args, **kwargs):
username = getattr(request.ctx, "username", None)
extra = username or None
start = time.perf_counter()
ws_id = log_ws_open(request, extra=extra)
close_extra = None
try: try:
await auth.verify(request) await auth.verify(request)
await handler(request, ws, *args, **kwargs) await handler(request, ws, *args, **kwargs)
except (
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosedError,
):
# Normal websocket closure - already logged in access log
pass
except Exception as e: except Exception as e:
context, code, message = {}, 500, str(e) or "Internal Server Error" context, code, message = {}, 500, str(e) or "Internal Server Error"
if isinstance(e, SanicException): if isinstance(e, SanicException):
@@ -66,6 +83,21 @@ def websocket_wrapper(handler):
await asend(ws, ErrorMsg({"code": code, "message": message, **context})) await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
if not getattr(e, "quiet", False) or code == 500: if not getattr(e, "quiet", False) or code == 500:
logger.exception(f"{code} {e!r}") logger.exception(f"{code} {e!r}")
close_extra = f"{code} {message}"
raise raise
finally:
duration = time.perf_counter() - start
close_code = None
try:
p = ws.ws_proto
if p.close_rcvd is not None:
close_code = p.close_rcvd.code
elif p.close_sent is not None:
close_code = p.close_sent.code
elif getattr(p, "close_code", None) is not None:
close_code = p.close_code
except AttributeError:
pass
log_ws_close(ws_id, close_code, duration, extra=close_extra)
return wrapper return wrapper
+5 -5
View File
@@ -23,7 +23,7 @@ class AsyncLink:
@property @property
def to_sync(self): def to_sync(self):
"""Yield SyncRequests from async caller when called from worker thread.""" """Yield SyncRequests from async caller when called from worker thread."""
while (req := self._await(self._get())) is not None: while (req := self.await_sync(self._get())) is not None:
yield SyncRequest(self, req) yield SyncRequest(self, req)
async def _get(self): async def _get(self):
@@ -33,14 +33,14 @@ class AsyncLink:
self.queue.task_done() self.queue.task_done()
return ret return ret
def _await(self, coro): def await_sync(self, coro):
"""Run coroutine in main thread and return result; called from worker.""" """Run coroutine in main thread and return result; called from worker."""
return asyncio.run_coroutine_threadsafe(coro, self.loop).result() return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
async def stop(self): async def stop(self):
"""Stop worker and clean up.""" """Stop worker and clean up."""
while not self.queue.empty(): while not self.queue.empty():
command, future = self.queue.get_nowait() _command, future = self.queue.get_nowait()
if not future.done(): if not future.done():
future.set_exception(Exception("AsyncLink stopped")) future.set_exception(Exception("AsyncLink stopped"))
self.queue.task_done() self.queue.task_done()
@@ -87,9 +87,9 @@ class SyncRequest:
def set_result(self, value): def set_result(self, value):
"""Set result value; mark as done.""" """Set result value; mark as done."""
self.done = True self.done = True
self.alink._await(set_result(self.future, value)) self.alink.await_sync(set_result(self.future, value))
def set_exception(self, exc): def set_exception(self, exc):
"""Set exception; mark as done.""" """Set exception; mark as done."""
self.done = True self.done = True
self.alink._await(set_result(self.future, exception=exc)) self.alink.await_sync(set_result(self.future, exception=exc))
+49
View File
@@ -0,0 +1,49 @@
import shutil
import threading
import time
from pathlib import Path
MIN_FREE_BYTES = 128 * 1024 * 1024
_CHECK_CACHE_TTL = 1.0
class InsufficientStorageError(Exception):
"""Raised when there is not enough disk space for an operation."""
_cache: dict[Path, tuple[float, int]] = {}
_lock = threading.Lock()
def check_free_space(path: Path) -> None:
"""Raise InsufficientStorageError if free space on the filesystem containing *path*
is below MIN_FREE_BYTES. Results are cached per directory for 1 second.
"""
check_path = path.parent if path.parent.exists() else path
check_path = check_path.resolve()
now = time.monotonic()
with _lock:
ts, free = _cache.get(check_path, (0, 0))
if now - ts < _CHECK_CACHE_TTL:
if free < MIN_FREE_BYTES:
raise InsufficientStorageError(
f"Insufficient storage: {free} bytes free, "
f"need at least {MIN_FREE_BYTES} bytes"
)
return
try:
free = shutil.disk_usage(check_path).free
except OSError as e:
raise InsufficientStorageError(f"Cannot check disk usage: {e}") from e
with _lock:
_cache[check_path] = (now, free)
if free < MIN_FREE_BYTES:
raise InsufficientStorageError(
f"Insufficient storage: {free} bytes free, "
f"need at least {MIN_FREE_BYTES} bytes"
)
+9 -7
View File
@@ -1,5 +1,5 @@
from collections.abc import Callable
from time import monotonic from time import monotonic
from typing import Callable
class LRUCache: class LRUCache:
@@ -7,22 +7,22 @@ class LRUCache:
LRUCache is a least-recently-used (LRU) cache with expiry time. LRUCache is a least-recently-used (LRU) cache with expiry time.
Attributes: Attributes:
open (callable): Function to open a new handle. opener (callable): Function to open a new handle.
capacity (int): Max number of items in the cache. capacity (int): Max number of items in the cache.
maxage (float): Max age for items in cache in seconds. maxage (float): Max age for items in cache in seconds.
cache (list): Internal list storing the cache items. cache (list): Internal list storing the cache items.
""" """
def __init__(self, open: Callable, *, capacity: int, maxage: float): def __init__(self, opener: Callable, *, capacity: int, maxage: float):
""" """
Initialize LRUCache. Initialize LRUCache.
Args: Args:
open (callable): Function to open a new handle. opener (callable): Function to open a new handle.
capacity (int): Maximum capacity of the cache. capacity (int): Maximum capacity of the cache.
maxage (float): Max age for items in cache in seconds. maxage (float): Max age for items in cache in seconds.
""" """
self.open = open self.opener = opener
self.capacity = capacity self.capacity = capacity
self.maxage = maxage self.maxage = maxage
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
@@ -47,7 +47,7 @@ class LRUCache:
self.cache.pop(i) self.cache.pop(i)
break break
else: else:
f = self.open(key) f = self.opener(key)
# Add/restore to end of cache # Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic())) self.cache.insert(0, (key, f, monotonic()))
self.expire_items() self.expire_items()
@@ -58,7 +58,9 @@ class LRUCache:
Expire items that are either too old or exceed cache capacity. Expire items that are either too old or exceed cache capacity.
""" """
ts = monotonic() - self.maxage ts = monotonic() - self.maxage
while len(self.cache) > self.capacity or self.cache and self.cache[-1][2] < ts: while len(self.cache) > self.capacity or (
self.cache and self.cache[-1][2] < ts
):
self.cache.pop()[1].close() self.cache.pop()[1].close()
def close(self): def close(self):
+1028 -53
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
import hmac
import re
from typing import Protocol
from unicodedata import normalize
import argon2
_argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
class SupportsHash(Protocol):
hash: str
def normalize_secret(value: str) -> bytes:
return normalize("NFC", value).strip().encode()
def verify_hash(user_hash: str, *, username: str, password: str) -> bool:
"""Verify password hash and return whether the stored hash should be upgraded."""
if not user_hash:
raise ValueError("Account disabled")
normalized_username = normalize_secret(username)
normalized_password = normalize_secret(password)
if (match := _droppyhash.match(user_hash)) is not None:
expected_hash, salt = match.groups()
computed_hash = hmac.digest(
normalized_password + salt.encode() + normalized_username,
b"",
"sha256",
).hex()
if not hmac.compare_digest(expected_hash, computed_hash):
raise ValueError("Invalid password")
return True
try:
_argon.verify(user_hash, normalized_password)
except Exception:
raise ValueError("Invalid password") from None
return _argon.check_needs_rehash(user_hash)
def set_password(user: SupportsHash, password: str) -> None:
user.hash = _argon.hash(normalize_secret(password))
+105 -24
View File
@@ -17,6 +17,11 @@ from cista import config
from cista.fileio import fuid from cista.fileio import fuid
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
try:
import inotify.adapters as inotify_adapters
except Exception:
inotify_adapters = None
# Platform-specific allocated size calculation # Platform-specific allocated size calculation
if sys.platform == "win32": if sys.platform == "win32":
import ctypes import ctypes
@@ -30,6 +35,7 @@ if sys.platform == "win32":
def get_allocated_size(path: Path, st: stat_result) -> int: def get_allocated_size(path: Path, st: stat_result) -> int:
"""Get actual disk allocation on Windows using GetCompressedFileSizeW.""" """Get actual disk allocation on Windows using GetCompressedFileSizeW."""
_ = st
high = wintypes.DWORD() high = wintypes.DWORD()
low = GetCompressedFileSizeW(str(path), ctypes.byref(high)) low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0: if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
@@ -40,6 +46,7 @@ else:
def get_allocated_size(path: Path, st: stat_result) -> int: def get_allocated_size(path: Path, st: stat_result) -> int:
"""Get actual disk allocation on Unix using st_blocks.""" """Get actual disk allocation on Unix using st_blocks."""
_ = path
# st_blocks is in 512-byte units # st_blocks is in 512-byte units
return st.st_blocks * 512 return st.st_blocks * 512
@@ -48,6 +55,14 @@ pubsub = {}
sortkey = natsort_keygen(alg=ns.LOCALE) sortkey = natsort_keygen(alg=ns.LOCALE)
class FormatUpdateLoopError(RuntimeError):
pass
class _WatcherStoppingError(Exception):
"""Internal control-flow exception for quick watcher shutdown."""
class State: class State:
def __init__(self): def __init__(self):
self.lock = threading.RLock() self.lock = threading.RLock()
@@ -141,18 +156,28 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
state = State() state = State()
rootpath: Path = None # type: ignore rootpath: Path | None = None
quit = threading.Event() stop_event = threading.Event()
# Thread-safe queue for signaling path updates from websockets # Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue() _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
# Thread-safe queue for AR updates from the preview worker
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
_ar_map: dict[str, float] = {}
def notify_ar(fuid_key: str, ar: float) -> None:
"""Called from preview handler to update the AR for a file."""
_ar_queue.put_nowait((fuid_key, ar))
def notify_change(*paths: PurePosixPath | str): def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets.""" """Signal that paths have changed. Called from control/upload websockets."""
for path in paths: for raw_path in paths:
if isinstance(path, str): path = PurePosixPath(raw_path) if isinstance(raw_path, str) else raw_path
path = PurePosixPath(path)
# Convert absolute paths to relative (strip leading /) # Convert absolute paths to relative (strip leading /)
if path.is_absolute(): if path.is_absolute():
path = ( path = (
@@ -180,23 +205,25 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
except Exception: except Exception:
logger.exception(f"get_allocated_size failed for {path}") logger.exception(f"get_allocated_size failed for {path}")
allocated = st.st_size if isfile else 0 allocated = st.st_size if isfile else 0
key = fuid(st)
entry = FileEntry( entry = FileEntry(
level=len(rel.parts), level=len(rel.parts),
name=rel.name, name=rel.name,
key=fuid(st), key=key,
mtime=int(st.st_mtime), mtime=int(st.st_mtime),
size=st.st_size if isfile else 0, size=st.st_size if isfile else 0,
allocated=allocated, allocated=allocated,
isfile=isfile, isfile=isfile,
ar=_ar_map.get(key) if isfile else None,
) )
if isfile: if isfile:
return [entry] return [entry]
# Walk all entries of the directory # Walk all entries of the directory
ret: list[FileEntry] = [...] # type: ignore ret: list[FileEntry] = [...] # type: ignore[assignment]
li = [] li = []
for f in path.iterdir(): for f in path.iterdir():
if quit.is_set(): if stop_event.is_set():
raise SystemExit("quit") raise _WatcherStoppingError
if f.name.startswith("."): if f.name.startswith("."):
continue # No dotfiles continue # No dotfiles
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
@@ -208,7 +235,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
li.append((int(isfile), f.name, s)) li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries # Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li): for [_, name, s] in humansorted(li):
if stop_event.is_set():
raise _WatcherStoppingError
sub = walk(rel / name, stat=s) sub = walk(rel / name, stat=s)
if not sub:
continue
child = sub[0] child = sub[0]
entry = FileEntry( entry = FileEntry(
level=entry.level, level=entry.level,
@@ -245,6 +276,7 @@ def update_root(loop):
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop): def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
"""Called on FS updates, check the filesystem and broadcast any changes.""" """Called on FS updates, check the filesystem and broadcast any changes."""
new = walk(relpath) new = walk(relpath)
_ = loop
obegin, old = treeget(rootmod, relpath) obegin, old = treeget(rootmod, relpath)
if old == new: if old == new:
@@ -301,7 +333,7 @@ def format_update(old, new):
logger.error( logger.error(
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}" f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
) )
raise Exception( raise FormatUpdateLoopError(
f"format_update infinite loop detected at iteration {iteration_count}" f"format_update infinite loop detected at iteration {iteration_count}"
) )
@@ -508,7 +540,7 @@ class PathIndex:
if lo < len(children): if lo < len(children):
return children[lo] return children[lo]
elif children: if children:
# Insert after last child's subtree # Insert after last child's subtree
last_idx = children[-1] last_idx = children[-1]
last_entry = self.root[last_idx] last_entry = self.root[last_idx]
@@ -637,13 +669,14 @@ DEBOUNCE_MAX = 0.1 # But no more than 100ms total
def watcher(loop): def watcher(loop):
"""Unified watcher thread handling inotify, websocket signals, and periodic scans.""" """Unified watcher thread handling inotify, websocket signals, and periodic scans."""
use_inotify = sys.platform == "linux" use_inotify = sys.platform == "linux" and inotify_adapters is not None
inotify_tree = None inotify_tree = None
modified_flags = frozenset() modified_flags = frozenset()
if use_inotify: if sys.platform == "linux" and inotify_adapters is None:
import inotify.adapters logger.warning("inotify unavailable; falling back to periodic scanning")
if use_inotify:
modified_flags = frozenset( modified_flags = frozenset(
( (
"IN_CREATE", "IN_CREATE",
@@ -656,14 +689,15 @@ def watcher(loop):
) )
) )
while not quit.is_set(): while not stop_event.is_set():
if use_inotify: if use_inotify:
import inotify.adapters inotify_tree = inotify_adapters.InotifyTree(rootpath.as_posix())
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
update_root(loop) try:
update_root(loop)
except _WatcherStoppingError:
return
path_index = PathIndex(state.root[:]) path_index = PathIndex(state.root[:])
trefresh = time.monotonic() + 300.0 trefresh = time.monotonic() + 300.0
@@ -674,7 +708,11 @@ def watcher(loop):
first_event_time: float | None = None first_event_time: float | None = None
last_event_time: float | None = None last_event_time: float | None = None
def add_dirty(path: PurePosixPath, source: str) -> bool: def add_dirty(
path: PurePosixPath,
source: str,
dirty_paths=dirty_paths,
) -> bool:
"""Add path to dirty set. Returns True if added, False if redundant.""" """Add path to dirty set. Returns True if added, False if redundant."""
nonlocal first_event_time, last_event_time nonlocal first_event_time, last_event_time
# Check if already covered by an existing dirty path # Check if already covered by an existing dirty path
@@ -708,7 +746,7 @@ def watcher(loop):
last_event_time = now last_event_time = now
return True return True
while not quit.is_set(): while not stop_event.is_set():
now = time.monotonic() now = time.monotonic()
# Full refresh every 300s # Full refresh every 300s
@@ -743,7 +781,10 @@ def watcher(loop):
# Process each collapsed path # Process each collapsed path
new_root = path_index.root new_root = path_index.root
for path in collapsed: for path in collapsed:
new_entries = walk(path) try:
new_entries = walk(path)
except _WatcherStoppingError:
return
new_root = path_index.apply_update(path, new_entries) new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed # Broadcast if changed
@@ -762,12 +803,51 @@ def watcher(loop):
with state.lock: with state.lock:
broadcast(update_msg, loop) broadcast(update_msg, loop)
state.root = fresh state.root = fresh
except _WatcherStoppingError:
return
except Exception: except Exception:
logger.exception("Fallback failed; sending full root") logger.exception("Fallback failed; sending full root")
with state.lock: with state.lock:
broadcast(format_root(fresh), loop) broadcast(format_root(fresh), loop)
state.root = fresh state.root = fresh
# Drain AR updates from preview worker (immediate, no debounce)
ar_new_root: list[FileEntry] | None = None
try:
while True:
fuid_key, ar = _ar_queue.get_nowait()
_ar_map[fuid_key] = ar
# Patch the matching entry in the current root
root_to_patch = (
ar_new_root if ar_new_root is not None else path_index.root
)
for i, entry in enumerate(root_to_patch):
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
if ar_new_root is None:
ar_new_root = root_to_patch[:]
ar_new_root[i] = FileEntry(
level=entry.level,
name=entry.name,
key=entry.key,
mtime=entry.mtime,
size=entry.size,
allocated=entry.allocated,
isfile=entry.isfile,
ar=ar,
)
break
except queue.Empty:
pass
if ar_new_root is not None:
try:
update_msg = format_update(state.root, ar_new_root)
with state.lock:
broadcast(update_msg, loop)
state.root = ar_new_root
path_index = PathIndex(ar_new_root)
except Exception:
logger.exception("AR update broadcast failed")
# Collect events from websocket signals (non-blocking) # Collect events from websocket signals (non-blocking)
try: try:
while True: while True:
@@ -779,7 +859,7 @@ def watcher(loop):
# Collect inotify events if available (short timeout for responsiveness) # Collect inotify events if available (short timeout for responsiveness)
if inotify_tree: if inotify_tree:
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05): for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
if quit.is_set(): if stop_event.is_set():
return return
if not (modified_flags & set(event[1])): if not (modified_flags & set(event[1])):
continue continue
@@ -813,6 +893,7 @@ def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
stop_event.clear()
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher, target=watcher,
args=[app.loop], args=[app.loop],
@@ -823,5 +904,5 @@ def start(app):
def stop(app): def stop(app):
quit.set() stop_event.set()
app.ctx.watcher.join() app.ctx.watcher.join()
+28
View File
@@ -0,0 +1,28 @@
services:
onlyoffice:
build:
context: ./docker/onlyoffice-converter-patch
args:
ONLYOFFICE_VERSION: "9.3.1"
container_name: onlyoffice
ports:
- "8080:80"
environment:
# Number of converter workers (default 8).
# Set to your CPU count or slightly below.
- WORKERS
# JWT secret shared with Cista.
# OnlyOffice reads it as JWT_SECRET; Cista reads it as ONLYOFFICE_JWT_SECRET.
# We use ONLYOFFICE_JWT_SECRET as the canonical name so you only set one variable.
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
- JWT_ENABLED=true
- JWT_HEADER=Authorization
volumes:
# Persist fonts and generated caches across restarts
- onlyoffice-data:/var/www/onlyoffice/Data
- onlyoffice-lib:/var/lib/onlyoffice
restart: unless-stopped
volumes:
onlyoffice-data:
onlyoffice-lib:
@@ -0,0 +1,70 @@
# Patched OnlyOffice Document Server with configurable converter worker count.
#
# The Community Edition hardcodes the document converter to 1 worker,
# which creates a severe bottleneck under concurrent load.
# This image patches the open-source license.js to spawn a configurable
# number of converter workers (default 8).
#
# Build:
# docker build -t onlyoffice-cista docker/onlyoffice-converter-patch
#
# Run:
# docker run -d -p 8988:80 \
# -e WORKERS=16 \
# -e JWT_SECRET=your-strong-secret \
# --name onlyoffice onlyoffice-cista
#
# JWT:
# Set JWT_SECRET to the same value you pass to Cista as ONLYOFFICE_JWT_SECRET.
# OnlyOffice will enable token validation automatically.
#
# The ONLYOFFICE_VERSION build arg lets you target a specific release.
ARG ONLYOFFICE_VERSION=9.3.1
FROM onlyoffice/documentserver:${ONLYOFFICE_VERSION}
# Prevent interactive apt prompts
ENV DEBIAN_FRONTEND=noninteractive
# Install Node.js, npm, and git so we can run the FileConverter from source.
RUN apt-get update -qq && \
apt-get install -y -qq --no-install-recommends \
nodejs \
npm \
git \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Clone the open-source server components (shallow, ~15 MB).
# The master branch is used because the Linux/web tags are not published
# in the server repo; the license.js file has been stable for years.
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
# Patch license.js so the converter worker count is read from an env var
# instead of being hardcoded to 1.
RUN sed -i \
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
/opt/oo-server/Common/sources/license.js
# Install npm dependencies for the modules the FileConverter touches.
# DocService deps are also needed because converter.js pulls in baseConnector.
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
RUN cd /opt/oo-server/FileConverter && npm ci --no-audit --no-fund
RUN cd /opt/oo-server/DocService && npm ci --no-audit --no-fund
# Back up the compiled pkg binary and replace it with our wrapper.
RUN mv /var/www/onlyoffice/documentserver/server/FileConverter/converter \
/var/www/onlyoffice/documentserver/server/FileConverter/converter.orig
COPY converter-wrapper.sh /var/www/onlyoffice/documentserver/server/FileConverter/converter
RUN chmod +x /var/www/onlyoffice/documentserver/server/FileConverter/converter
# Default worker count (override at runtime with -e WORKERS=16).
ENV WORKERS=8
# Use our custom entrypoint to persist the env var to a file that the
# non-root converter process (user=ds) can read.
COPY entrypoint.sh /app/ds/run-document-server-patched.sh
RUN chmod +x /app/ds/run-document-server-patched.sh
ENTRYPOINT ["/app/ds/run-document-server-patched.sh"]
@@ -0,0 +1,19 @@
#!/bin/bash
# Wrapper that runs the OnlyOffice FileConverter from patched Node.js source.
# Replaces the compiled pkg binary shipped with the Community Edition.
# The env var is not passed through supervisor to the 'ds' user, so we read
# it from a file written by the custom entrypoint.
if [ -z "${WORKERS}" ] && [ -r /tmp/oo-converter-workers.txt ]; then
export WORKERS=$(cat /tmp/oo-converter-workers.txt)
fi
cd /opt/oo-server/FileConverter || exit 1
export NODE_ENV=production-linux
export NODE_CONFIG_DIR=/etc/onlyoffice/documentserver
export NODE_DISABLE_COLORS=1
export APPLICATION_NAME=onlyoffice
export LD_LIBRARY_PATH=/var/www/onlyoffice/documentserver/server/FileConverter/bin
exec node sources/convertermaster.js "$@"
@@ -0,0 +1,8 @@
#!/bin/bash
# Custom entrypoint that persists WORKERS to a file readable by
# the non-root user that supervisor uses to run the converter.
echo "${WORKERS:-8}" > /tmp/oo-converter-workers.txt
chmod 644 /tmp/oo-converter-workers.txt
exec /app/ds/run-document-server.sh "$@"
+69
View File
@@ -0,0 +1,69 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"ignore": ["node_modules", "dist", "coverage", "components.d.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 88
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noInferrableTypes": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"noUselessElse": "off",
"useExponentiationOperator": "off",
"useSingleVarDeclarator": "off",
"useTemplate": "off",
"useConst": "off",
"useImportType": "off"
},
"suspicious": {
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noMisleadingCharacterClass": "off"
},
"complexity": {
"noBannedTypes": "off",
"useOptionalChain": "off"
},
"correctness": {
"noSwitchDeclarations": "off"
},
"a11y": {
"useGenericFontNames": "off"
}
}
},
"overrides": [
{
"include": ["**/*.d.ts"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
},
"complexity": {
"noBannedTypes": "off"
}
}
}
}
],
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "asNeeded"
}
}
}
+9 -23
View File
@@ -6,18 +6,22 @@
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "biome lint .",
"format": "prettier --write src/" "format": "biome format --write .",
"format:check": "biome format --check .",
"check": "biome check ."
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@imengyu/vue3-context-menu": "^1.5.3", "@imengyu/vue3-context-menu": "^1.5.3",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"codemirror": "^6.0.2",
"esbuild": "^0.27.2", "esbuild": "^0.27.2",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lodash-es": "^4.17.23", "lodash-es": "^4.17.23",
@@ -30,33 +34,15 @@
"vue-router": "^5.0.1" "vue-router": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.15.0", "@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"babel-eslint": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"prettier": "^3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 88
} }
} }
+218 -44
View File
@@ -7,13 +7,37 @@
</div> </div>
<SettingsModal /> <SettingsModal />
<UserManagementModal /> <UserManagementModal />
<UserTokensModal />
<AboutModal />
<AccessDeniedModal /> <AccessDeniedModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" /> <HeaderMain
<BreadCrumb :path="path.pathList" primary /> ref="headerMain"
:path="path.pathList"
:query="path.query"
:editor-mode="path.isEditorPath"
/>
<BreadCrumb
:path="path.breadcrumbPathList"
:links="path.breadcrumbLinks"
primary
/>
</header> </header>
<main> <main class="transition-wrapper">
<RouterView :path="path.pathList" :query="path.query" /> <Transition
:name="routeTransitionName"
@after-enter="store.transitionDirection = 'none'"
>
<div :key="routeViewKey" class="explorer-content">
<KeepAlive>
<component
:is="routeViewComponent"
:key="routeViewKey"
v-bind="routeViewProps"
/>
</KeepAlive>
</div>
</Transition>
</main> </main>
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total"> <footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<SelectionToolbar :path="path.pathList" /> <SelectionToolbar :path="path.pathList" />
@@ -23,40 +47,110 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue' import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed } from 'vue'
import Router from '@/router/index' import Router from '@/router/index'
import type { SortOrder } from './utils/docsort' import { computed } from 'vue'
import type SettingsModalVue from './components/SettingsModal.vue' import AboutModal from './components/AboutModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue' import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue' import SelectionToolbar from './components/SelectionToolbar.vue'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue'
import type { SortOrder } from './utils/docsort'
import ExplorerView from './views/ExplorerView.vue'
import TextEditorView from './views/TextEditorView.vue'
interface Path { interface Path {
path: string path: string
canonicalPath: string
isEditorPath: boolean
pathList: string[] pathList: string[]
breadcrumbPathList: string[]
breadcrumbLinks?: string[]
query: string query: string
} }
const store = useMainStore() const store = useMainStore()
const getDocByPath = (fullPath: string) =>
getDocuments().find(
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
)
const path: ComputedRef<Path> = computed(() => { const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//') const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = (p[0] ?? '').split('/').filter(value => value !== '') const rawPath = p[0] ?? ''
const routePathList = rawPath.split('/').filter(value => value !== '')
const query = p.slice(1).join('//') const query = p.slice(1).join('//')
const fullPath = routePathList.join('/')
// Access docVersion to make route mode reactive to tree updates
void store.docVersion
const doc = fullPath ? getDocByPath(fullPath) : null
const isEditorPath = !!(doc && !doc.dir && doc.text)
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
const canonicalPath = query
? `${rawPath}//${query}` // keep search URL shape untouched
: canonicalBase
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
const breadcrumbPathList = routePathList
const breadcrumbLinks = isEditorPath
? [
'/',
...routePathList
.slice(0, -1)
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
`/${fullPath}`
]
: undefined
return { return {
path: p[0] ?? '', path: rawPath,
canonicalPath,
isEditorPath,
pathList, pathList,
breadcrumbPathList,
breadcrumbLinks,
query query
} }
}) })
watchEffect(() => { const routeTransitionName = computed(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage' if (store.transitionDirection === 'forward') return 'slide-forward'
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
}) })
const routeViewComponent = computed(() =>
path.value.isEditorPath ? TextEditorView : ExplorerView
)
const routeViewKey = computed(() => {
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
})
const routeViewProps = computed(() =>
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
)
watch(
() => path.value.canonicalPath,
canonical => {
const current = decodeURIComponent(Router.currentRoute.value.path)
if (canonical && current !== canonical) {
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
}
},
{ immediate: true }
)
watch(
() => path.value.path,
() => {
document.title =
path.value.path.replace(/\/$/, '').split('/').pop() ||
store.server.name ||
'Cista Storage'
},
{ immediate: true }
)
onMounted(loadSession) onMounted(loadSession)
onMounted(watchConnect) onMounted(watchConnect)
onUnmounted(watchDisconnect) onUnmounted(watchDisconnect)
@@ -75,7 +169,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = store.fileExplorer as any const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return if (!fileExplorer) return
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT' const target = event.target as HTMLElement
const input =
['INPUT', 'TEXTAREA'].includes(target.tagName) || !!target.closest('.cm-editor')
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input // Always clear repeat timer on arrow keyup, even if focus moved to input
@@ -90,6 +186,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
event.key === 'ArrowDown' || event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' || event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' || event.key === 'ArrowRight' ||
event.key === 'PageUp' ||
event.key === 'PageDown' ||
(c && event.code === 'Space') (c && event.code === 'Space')
) { ) {
if (!input) event.preventDefault() if (!input) event.preventDefault()
@@ -99,28 +197,47 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
//console.log("key pressed", event) //console.log("key pressed", event)
/// Long if-else machina for all keys we handle here /// Long if-else machina for all keys we handle here
let arrow = '' let arrow = ''
let paging = ''
const inHeader = !!(event.target as HTMLElement).closest('.headermain') const inHeader = !!(event.target as HTMLElement).closest('.headermain')
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb') const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
// Handle arrows: in search input with text, only up/down; otherwise all arrows // Handle arrows: in search input with text, only up/down; otherwise all arrows
const searchInput = inHeader && input const searchInput = inHeader && input
const searchHasText = searchInput && (event.target as HTMLInputElement).value const searchHasText = searchInput && (event.target as HTMLInputElement).value
if (event.key.startsWith("Arrow")) { if (event.key.startsWith('Arrow')) {
const dir = event.key.slice(5).toLowerCase() const dir = event.key.slice(5).toLowerCase()
// In search with text: left/right move cursor, up/down navigate // In search with text: left/right move cursor, up/down navigate
if (searchHasText && (dir === 'left' || dir === 'right')) { if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement return // Let browser handle cursor movement
} }
// Don't intercept arrows for non-search inputs (e.g. rename input)
if (input && !searchInput) return
arrow = dir arrow = dir
} else if (
event.key === 'PageUp' ||
event.key === 'PageDown' ||
event.key === 'Home' ||
event.key === 'End'
) {
if (input) return
paging = event.key
} }
if (arrow) { if (arrow) {
// Arrow key handling - fall through to bottom // Arrow key handling - fall through to bottom
} else if (paging) {
// Paging/navigation key handling - fall through to bottom
} }
// Find: process on keydown so that we can bypass the built-in search hotkey // Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { else if (
!path.value.isEditorPath &&
!input &&
!keyup &&
event.key === 'f' &&
(event.ctrlKey || event.metaKey)
) {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Search also on / (UNIX style) - use code to support any keyboard layout // Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.code === 'Slash') { else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
// Record the actual character for display (varies by keyboard layout) // Record the actual character for display (varies by keyboard layout)
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) { if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
store.prefs.searchHotkey = event.key store.prefs.searchHotkey = event.key
@@ -131,10 +248,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
store.clearToast() store.clearToast()
headerMain.value!.clearSearch(event) // Keep rename and other non-search inputs isolated from search behavior.
if (input && !searchInput) return
if (!path.value.isEditorPath) {
headerMain.value!.clearSearch(event)
}
store.focusBreadcrumb() store.focusBreadcrumb()
} } else if (!input && keyup && event.key === 'Backspace') {
else if (!input && keyup && event.key === 'Backspace') {
Router.back() Router.back()
} }
// Select all (toggle); keydown to precede and prevent builtin // Select all (toggle); keydown to precede and prevent builtin
@@ -149,20 +269,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if ( else if (
!input && !input &&
keyup && keyup &&
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3') (event.code === 'Backquote' ||
event.key === '1' ||
event.key === '2' ||
event.key === '3')
) { ) {
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder) store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
} }
// Rename // Rename
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) { else if (
!input &&
c &&
keyup &&
!event.ctrlKey &&
(event.key === 'F2' || event.key === 'r')
) {
fileExplorer.cursorRename() fileExplorer.cursorRename()
} }
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey // Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (!input && c && event.code === 'Space') { else if (!input && c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey) if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
fileExplorer.cursorSelect() } else return
}
else return
/// We are handling this! /// We are handling this!
event.preventDefault() event.preventDefault()
if (timer) { if (timer) {
@@ -172,36 +299,83 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
let f: any let f: any
// Arrow navigation - always use fileExplorer for repeatable movement // Arrow navigation - always use fileExplorer for repeatable movement
if (arrow && !keyup) { if (arrow && !keyup) {
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus() const focusSearch = () =>
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus() (
document.querySelector('.headermain input[type="search"]') as HTMLElement
)?.focus()
const focusBreadcrumb = () =>
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
if (inBreadcrumb) { if (inBreadcrumb) {
// Breadcrumb: up→header (no repeat), down→files (with repeat) // Breadcrumb: up→header (no repeat), down→files (with repeat)
if (arrow === 'up') { focusSearch(); f = null } if (arrow === 'up') {
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null } focusSearch()
f = null
} else if (arrow === 'down') {
fileExplorer.focusFirst?.()
f = null
}
} else if (inHeader) { } else if (inHeader) {
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space) // Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[] const items = Array.from(
document.querySelectorAll(
'.headermain button:not([tabindex="-1"]), .headermain input[type="search"], .headermain [tabindex="0"]'
)
) as HTMLElement[]
const idx = items.indexOf(document.activeElement as HTMLElement) const idx = items.indexOf(document.activeElement as HTMLElement)
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null } if (arrow === 'left' && idx > 0) {
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null } items[idx - 1]?.focus()
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false }) f = null
else if (arrow === 'down') { focusBreadcrumb(); f = null } } else if (arrow === 'right' && idx < items.length - 1) {
items[idx + 1]?.focus()
f = null
} else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
else if (arrow === 'down') {
focusBreadcrumb()
f = null
}
} else { } else {
// File explorer: normal navigation with repeat // File explorer: normal navigation with repeat
switch (arrow) { switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break case 'up':
case 'down': f = () => fileExplorer.down(event); break f = () => fileExplorer.up(event)
case 'left': f = () => fileExplorer.left(event); break break
case 'right': f = () => fileExplorer.right(event); break case 'down':
f = () => fileExplorer.down(event)
break
case 'left':
f = () => fileExplorer.left(event)
break
case 'right':
f = () => fileExplorer.right(event)
break
} }
} }
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
switch (paging) {
case 'PageUp':
f = () => fileExplorer.pageUp?.(event)
break
case 'PageDown':
f = () => fileExplorer.pageDown?.(event)
break
case 'Home':
f = () => fileExplorer.home?.(event)
break
case 'End':
f = () => fileExplorer.end?.(event)
break
}
} }
if (f) { if (f) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100 const t0 = 200,
tr = event.altKey ? 20 : 100
f() f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr) if (paging === 'Home' || paging === 'End') return
timer = setTimeout(() => {
timer = setInterval(f, tr)
}, t0 - tr)
} }
} }
onMounted(() => { onMounted(() => {
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>

After

Width:  |  Height:  |  Size: 242 B

+83 -10
View File
@@ -24,7 +24,7 @@
--header-color: #ccc; --header-color: #ccc;
--input-background: var(--soft-color); --input-background: var(--soft-color);
--input-color: #ddd; --input-color: #ddd;
} }
} }
@media screen and (max-width: 600px) { @media screen and (max-width: 600px) {
.size, .size,
@@ -50,9 +50,75 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
header .headermain { order: 1; } header .headermain {
header .breadcrumb { align-self: stretch; } order: 1;
}
header .breadcrumb {
align-self: stretch;
}
} }
/* Directory navigation slide transitions */
.transition-wrapper {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: 100%;
}
.explorer-content {
grid-area: 1 / 1;
height: 100%;
}
.slide-forward-enter-active,
.slide-backward-enter-active {
z-index: 2;
}
.slide-forward-leave-active,
.slide-backward-leave-active {
z-index: 1;
}
.slide-forward-enter-active,
.slide-forward-leave-active,
.slide-backward-enter-active,
.slide-backward-leave-active {
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-forward-enter-from {
transform: translate3d(100%, 0, 0);
}
.slide-forward-enter-to {
transform: translate3d(0, 0, 0);
}
.slide-forward-leave-from {
transform: translate3d(0, 0, 0);
}
.slide-forward-leave-to {
transform: translate3d(-100%, 0, 0);
}
.slide-backward-enter-from {
transform: translate3d(-100%, 0, 0);
}
.slide-backward-enter-to {
transform: translate3d(0, 0, 0);
}
.slide-backward-leave-from {
transform: translate3d(0, 0, 0);
}
.slide-backward-leave-to {
transform: translate3d(100%, 0, 0);
}
@media print { @media print {
:root { :root {
--primary-color: black; --primary-color: black;
@@ -74,7 +140,7 @@
max-width: none !important; max-width: none !important;
} }
.breadcrumb > a::after { .breadcrumb > a::after {
content: '/'; content: "/";
} }
.breadcrumb svg { .breadcrumb svg {
fill: black !important; fill: black !important;
@@ -101,7 +167,8 @@
video::-webkit-media-controls { video::-webkit-media-controls {
display: none; display: none;
} }
tr, figure { tr,
figure {
page-break-inside: avoid; page-break-inside: avoid;
} }
.selection { .selection {
@@ -134,7 +201,7 @@ main {
body { body {
background-color: var(--primary-background); background-color: var(--primary-background);
font-size: 1rem; font-size: 1rem;
font-family: 'Roboto'; font-family: "Roboto";
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */ /* Prevent any scrolling on body */
@@ -145,7 +212,7 @@ body {
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
font-family: 'Roboto Mono'; font-family: "Roboto Mono";
} }
header { header {
flex: 0 0 auto; flex: 0 0 auto;
@@ -201,6 +268,8 @@ main {
min-height: 0; /* Allow flex child to shrink below content size */ min-height: 0; /* Allow flex child to shrink below content size */
padding-bottom: 3em; /* convenience space on the bottom */ padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden;
position: relative;
text-align: center; text-align: center;
} }
@@ -209,9 +278,13 @@ header nav.headermain {
position: relative; position: relative;
z-index: 100; z-index: 100;
} }
.spacer { flex-grow: 1 } .spacer {
.smallgap { flex-shrink: 1; width: 2em } flex-grow: 1;
}
.smallgap {
flex-shrink: 1;
width: 2em;
}
.error-message { .error-message {
padding: .5em; padding: .5em;
+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
</svg>

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 452 B

+102 -54
View File
@@ -60,78 +60,126 @@ import Zoomout from './zoomout.svg'
// Named exports for direct imports // Named exports for direct imports
export { export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV, AddFile,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross, AddFolder,
Disk, Download, Exclamation, Eye, Find, Fullscreen, Arrow,
Github, Home, Info, Link, Logo, Loop, Menu, ArrowsH,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous, ArrowsV,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip, Check,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow, Code,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow, Cog,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap, Copy,
Zoomin, Zoomout CreateFile,
CreateFolder,
Cross,
Disk,
Download,
Exclamation,
Eye,
Find,
Fullscreen,
Github,
Home,
Info,
Link,
Logo,
Loop,
Menu,
Next,
Open,
Paste,
Pause,
Pencil,
Play,
Plus,
Previous,
Reload,
Rename,
Scissors,
Shuffle,
Signin,
Signout,
Skip,
Spinner,
Stop,
Trash,
Triangle,
Unfullscreen,
UpArrow,
UploadCloud,
UserCog,
User,
VolumeHigh,
VolumeLow,
VolumeMedium,
VolumeMute,
WindowCross,
Window,
Wordwrap,
Zoomin,
Zoomout
} }
// Icon lookup by kebab-case name (for SvgButton compatibility) // Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = { export const icons = {
'add-file': AddFile, 'add-file': AddFile,
'add-folder': AddFolder, 'add-folder': AddFolder,
'arrow': Arrow, arrow: Arrow,
'arrows-h': ArrowsH, 'arrows-h': ArrowsH,
'arrows-v': ArrowsV, 'arrows-v': ArrowsV,
'check': Check, check: Check,
'code': Code, code: Code,
'cog': Cog, cog: Cog,
'copy': Copy, copy: Copy,
'create-file': CreateFile, 'create-file': CreateFile,
'create-folder': CreateFolder, 'create-folder': CreateFolder,
'cross': Cross, cross: Cross,
'disk': Disk, disk: Disk,
'download': Download, download: Download,
'exclamation': Exclamation, exclamation: Exclamation,
'eye': Eye, eye: Eye,
'find': Find, find: Find,
'fullscreen': Fullscreen, fullscreen: Fullscreen,
'github': Github, github: Github,
'home': Home, home: Home,
'info': Info, info: Info,
'link': Link, link: Link,
'logo': Logo, logo: Logo,
'loop': Loop, loop: Loop,
'menu': Menu, menu: Menu,
'next': Next, next: Next,
'open': Open, open: Open,
'paste': Paste, paste: Paste,
'pause': Pause, pause: Pause,
'pencil': Pencil, pencil: Pencil,
'play': Play, play: Play,
'plus': Plus, plus: Plus,
'previous': Previous, previous: Previous,
'reload': Reload, reload: Reload,
'rename': Rename, rename: Rename,
'scissors': Scissors, scissors: Scissors,
'shuffle': Shuffle, shuffle: Shuffle,
'signin': Signin, signin: Signin,
'signout': Signout, signout: Signout,
'skip': Skip, skip: Skip,
'spinner': Spinner, spinner: Spinner,
'stop': Stop, stop: Stop,
'trash': Trash, trash: Trash,
'triangle': Triangle, triangle: Triangle,
'unfullscreen': Unfullscreen, unfullscreen: Unfullscreen,
'up-arrow': UpArrow, 'up-arrow': UpArrow,
'upload-cloud': UploadCloud, 'upload-cloud': UploadCloud,
'user-cog': UserCog, 'user-cog': UserCog,
'user': User, user: User,
'volume-high': VolumeHigh, 'volume-high': VolumeHigh,
'volume-low': VolumeLow, 'volume-low': VolumeLow,
'volume-medium': VolumeMedium, 'volume-medium': VolumeMedium,
'volume-mute': VolumeMute, 'volume-mute': VolumeMute,
'window-cross': WindowCross, 'window-cross': WindowCross,
'window': Window, window: Window,
'wordwrap': Wordwrap, wordwrap: Wordwrap,
'zoomin': Zoomin, zoomin: Zoomin,
'zoomout': Zoomout, zoomout: Zoomout
} as const } as const
export type IconName = keyof typeof icons export type IconName = keyof typeof icons
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 492 B

+110
View File
@@ -0,0 +1,110 @@
<template>
<ModalDialog name="about" title="">
<div class="about-content">
<div class="about-logo-pane">
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
</div>
<div class="about-details">
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
<p class="about-link">
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
</p>
<div class="dialog-buttons about-actions">
<div class="spacer"></div>
<input id="close" type="reset" value="Close" class="button" @click="close" />
</div>
</div>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import logoUrl from '@/assets/logo-square.svg?url'
import ModalDialog from '@/components/ModalDialog.vue'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
const store = useMainStore()
const softwareVersion = computed(() => store.server.version || 'unknown')
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
const close = () => {
store.dialog = ''
}
</script>
<style scoped>
:deep(#about.modal-dialog) {
overflow: hidden;
}
.about-content {
display: grid;
grid-template-columns: 11rem minmax(0, 1fr);
align-items: stretch;
width: min(35rem, 92vw);
min-width: 0;
min-height: 0;
margin: -1rem;
overflow: hidden;
}
.about-logo-pane {
display: block;
padding: 0;
overflow: hidden;
}
.about-logo {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
margin: 0;
display: block;
}
.about-details {
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.25rem;
}
.about-name {
margin: 0;
}
.about-link {
margin: 0.65rem 0 1rem;
word-break: break-word;
}
.about-actions {
margin-top: auto;
}
@media (max-width: 40rem) {
.about-content {
grid-template-columns: 1fr;
width: min(24rem, 90vw);
}
.about-logo-pane {
width: 100%;
aspect-ratio: 1 / 1;
}
.about-logo {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
}
.about-details {
padding: 0.85rem;
}
}
</style>
+7 -20
View File
@@ -1,32 +1,19 @@
<template> <template>
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay"> <ModalDialog name="accessdenied" title="">
<div class="modal-dialog" id="accessdenied"> <div class="access-denied">
<div class="modal-content access-denied"> <p class="icon"></p>
<p class="icon"></p> <p class="message">Access Denied</p>
<p class="message">Access Denied</p> <button @click="reload" class="button">Reload</button>
<button @click="reload" class="button">Reload</button>
</div>
</div> </div>
</div> </ModalDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import ModalDialog from '@/components/ModalDialog.vue'
import { holdGlobalBackdrop } from 'paskia'
import { watchEffect } from 'vue'
const store = useMainStore()
const reload = () => { const reload = () => {
location.reload() location.reload()
} }
// Keep backdrop active when this dialog shows
watchEffect(() => {
if (store.dialog === 'accessdenied') {
holdGlobalBackdrop()
}
})
</script> </script>
<style scoped> <style scoped>
+43 -16
View File
@@ -8,7 +8,7 @@
@focus=focusCurrent @focus=focusCurrent
tabindex=0 tabindex=0
> >
<a href="#/" <a :href="`/#${urlAt(0)}`"
:ref="el => setLinkRef(0, el)" :ref="el => setLinkRef(0, el)"
class="home" class="home"
:class="{ current: !!isCurrent(0) }" :class="{ current: !!isCurrent(0) }"
@@ -22,7 +22,7 @@
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip> <CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a> </a>
<template v-for="(location, index) in longest" :key="index"> <template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`" <a :href="`/#${urlAt(index + 1)}`"
:class="{ current: !!isCurrent(index + 1) }" :class="{ current: !!isCurrent(index + 1) }"
:aria-current="isCurrent(index + 1)" :aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)" @click.prevent="navigate(index + 1)"
@@ -37,17 +37,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { Home } from '@/assets/svg' import { Home } from '@/assets/svg'
import { exists } from '@/utils/fileutil'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue' import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
const home = Home const home = Home
const router = useRouter() const router = useRouter()
const links = [] as Array<HTMLElement> const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el } const setLinkRef = (index: number, el: any) => {
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home if (el) links[index] = el
}
onBeforeUpdate(() => {
links.length = 1
}) // 1 to keep home
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map()) const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
@@ -58,12 +62,20 @@ const setPathTooltipRef = (index: number, el: any) => {
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
links?: Array<string>
primary?: boolean primary?: boolean
}>() }>()
const longest = ref<Array<string>>([]) const longest = ref<Array<string>>([])
const longestLinks = ref<Array<string>>(['/'])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined const defaultLinks = (segments: Array<string>) => [
'/',
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
]
const isCurrent = (index: number) =>
index == props.path.length ? 'location' : undefined
const focusCurrent = () => { const focusCurrent = () => {
nextTick(() => { nextTick(() => {
@@ -72,15 +84,24 @@ const focusCurrent = () => {
}) })
} }
const urlAt = (index: number) => {
const explicit = longestLinks.value[index]
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
}
const navigate = (index: number) => { const navigate = (index: number) => {
const link = links[index] const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/' const url = urlAt(index)
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/' const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '') const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history // Clicking on current link clears the rest of the path and adds new history
if (isCurrent(index)) { longest.value.splice(index); router.push(u) } if (isCurrent(index)) {
longest.value.splice(index)
longestLinks.value.splice(index + 1)
router.push(u)
}
// Moving along breadcrumbs doesn't create new history // Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u) else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs) // Nornal navigation from elsewhere (e.g. search result breadcrumbs)
@@ -94,27 +115,33 @@ const move = (dir: number) => {
} }
watchEffect(() => { watchEffect(() => {
const currentLinks = props.links ?? defaultLinks(props.path)
const longcut = longest.value.slice(0, props.path.length) const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index]) const same = longcut.every((value, index) => value === props.path[index])
// Navigated out of previous path, reset longest to current // Navigated out of previous path, reset longest to current
if (!same) longest.value = props.path if (!same) {
else if (props.path.length > longcut.length) { longest.value = props.path
longestLinks.value = currentLinks
} else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length)) longest.value = longcut.concat(props.path.slice(longcut.length))
} longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
else { } else {
// Prune deleted folders from longest // Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) { for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) { if (!exists(longest.value.slice(0, i + 1))) {
longest.value = longest.value.slice(0, i) longest.value = longest.value.slice(0, i)
longestLinks.value = longestLinks.value.slice(0, i + 1)
break break
} }
} }
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
} }
// If needed, focus primary navigation to new location // If needed, focus primary navigation to new location
if (props.primary) nextTick(() => { if (props.primary)
const act = document.activeElement as HTMLElement nextTick(() => {
if (!act || [...links, document.body].includes(act)) focusCurrent() const act = document.activeElement as HTMLElement
}) if (!act || [...links, document.body].includes(act)) focusCurrent()
})
}) })
</script> </script>
+66 -35
View File
@@ -26,7 +26,7 @@
</defs> </defs>
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'"> <g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="showOtherCategory ? 'url(#otherGradient)' : freeColor" :stroke-width="ringWidth" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
@@ -38,12 +38,12 @@
<g ref="labelsRef" class="pie-labels"> <g ref="labelsRef" class="pie-labels">
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text> <text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text> <text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text> <text v-if="showOtherCategory" :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
<defs> <defs>
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" /> <path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" /> <path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" /> <path v-if="showOtherCategory" :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
</defs> </defs>
<text class="pie-label-sub" fill="#93e"> <text class="pie-label-sub" fill="#93e">
@@ -52,7 +52,7 @@
<text class="pie-label-sub" :fill="freeColor"> <text class="pie-label-sub" :fill="freeColor">
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath> <textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
</text> </text>
<text class="pie-label-sub" fill="#d9f"> <text v-if="showOtherCategory" class="pie-label-sub" fill="#d9f">
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath> <textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
</text> </text>
</g> </g>
@@ -62,8 +62,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const store = useMainStore() const store = useMainStore()
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
const fmtSize = (bytes: number, angle: number) => { const fmtSize = (bytes: number, angle: number) => {
const s = formatGB(bytes) const s = formatGB(bytes)
const a = Math.abs(angle % 180) const a = Math.abs(angle % 180)
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s return Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s) ? `${s}.` : s
} }
const truncateLabel = (name: string, maxLen = 10): string => { const truncateLabel = (name: string, maxLen = 10): string => {
@@ -98,18 +98,30 @@ const truncateLabel = (name: string, maxLen = 10): string => {
return name.slice(0, maxLen - 1) + '…' return name.slice(0, maxLen - 1) + '…'
} }
const otherBytes = computed(() => Math.max(0, store.space.used - store.space.allocated))
const showOtherCategory = computed(() => {
const s = store.space
return !!s.disk && otherBytes.value / s.disk >= 0.01
})
const freeSliceBytes = computed(() =>
showOtherCategory.value
? store.space.free
: Math.max(0, store.space.disk - store.space.allocated)
)
// Calculate max label length based on angular gap to neighbor labels // Calculate max label length based on angular gap to neighbor labels
const storageMaxLen = computed(() => { const storageMaxLen = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return 10 if (!s.disk) return 10
// Sector spans in degrees // Sector spans in degrees
const storageSpan = (s.allocated / s.disk) * 360 const storageSpan = (s.allocated / s.disk) * 360
const freeSpan = (s.free / s.disk) * 360 const freeSpan = (freeSliceBytes.value / s.disk) * 360
const otherSpan = ((s.used - s.allocated) / s.disk) * 360 const otherSpan = (otherBytes.value / s.disk) * 360
// Angular gap from storage label midpoint to neighbor label midpoints // Angular gap from storage label midpoint to neighbor label midpoints
const gapToFree = (storageSpan + freeSpan) / 2 const gapToFree = (storageSpan + freeSpan) / 2
const gapToOther = (storageSpan + otherSpan) / 2 const minGap = showOtherCategory.value
const minGap = Math.min(gapToFree, gapToOther) ? Math.min(gapToFree, (storageSpan + otherSpan) / 2)
: gapToFree
// Allow longer names when there's sufficient gap to both neighbors // Allow longer names when there's sufficient gap to both neighbors
if (minGap > 70) return 18 if (minGap > 70) return 18
if (minGap > 55) return 14 if (minGap > 55) return 14
@@ -143,7 +155,7 @@ const pieStorageDash = computed(() => {
const pieFreeDash = computed(() => { const pieFreeDash = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return `0 ${CIRC}` if (!s.disk) return `0 ${CIRC}`
return `${(s.free / s.disk) * CIRC} ${CIRC}` return `${(freeSliceBytes.value / s.disk) * CIRC} ${CIRC}`
}) })
const pieFreeOffsetVal = computed(() => { const pieFreeOffsetVal = computed(() => {
@@ -157,7 +169,7 @@ const freeColor = computed(() => {
if (!s.disk) return '#6c6' if (!s.disk) return '#6c6'
const freePct = s.free / s.disk const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#ff0' if (freePct > 0.1) return '#ff0'
return '#f00' return '#f00'
}) })
@@ -165,23 +177,24 @@ const PIE_RADIUS = 55
const LABEL_RADIUS = 62 const LABEL_RADIUS = 62
const getPoint = (angle: number, radius: number) => { const getPoint = (angle: number, radius: number) => {
const rad = TAU * (angle - 90) / 360 const rad = (TAU * (angle - 90)) / 360
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) } return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
} }
const sectorInfo = computed(() => { const sectorInfo = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return { if (!s.disk)
storage: { angle: 45, pct: 0.25 }, return {
free: { angle: 180, pct: 0.5 }, storage: { angle: 45, pct: 0.25 },
other: { angle: 270, pct: 0.25 } free: { angle: 180, pct: 0.5 },
} other: { angle: 270, pct: 0.25 }
}
const storagePct = s.allocated / s.disk const storagePct = s.allocated / s.disk
const freePct = s.free / s.disk const freePct = freeSliceBytes.value / s.disk
const otherPct = (s.used - s.allocated) / s.disk const otherPct = showOtherCategory.value ? otherBytes.value / s.disk : 0
const storageAngle = storagePct * 180 // midpoint of storage sector const storageAngle = storagePct * 180 // midpoint of storage sector
const freeStart = storagePct * 360 const freeStart = storagePct * 360
const freeAngle = freeStart + freePct * 180 const freeAngle = freeStart + freePct * 180
const otherStart = (storagePct + freePct) * 360 const otherStart = (storagePct + freePct) * 360
@@ -197,22 +210,28 @@ const sectorInfo = computed(() => {
const rawAngles = computed(() => ({ const rawAngles = computed(() => ({
storage: sectorInfo.value.storage.angle, storage: sectorInfo.value.storage.angle,
free: sectorInfo.value.free.angle, free: sectorInfo.value.free.angle,
other: sectorInfo.value.other.angle ...(showOtherCategory.value ? { other: sectorInfo.value.other.angle } : {})
})) }))
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90 const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start' const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95 const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)) const storageInnerPos = computed(() =>
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)) getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS)) )
const freeInnerPos = computed(() =>
getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)
)
const otherInnerPos = computed(() =>
getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS)
)
// Collision avoidance for curved name labels // Collision avoidance for curved name labels
const labelLengths = computed(() => ({ const labelLengths = computed(() => ({
storage: storageName.value.length, storage: storageName.value.length,
free: 4, free: 4,
other: 5 ...(showOtherCategory.value ? { other: 5 } : {})
})) }))
const getGapForPair = (len1: number, len2: number) => { const getGapForPair = (len1: number, len2: number) => {
@@ -225,7 +244,9 @@ const adjustedLabelAngles = computed(() => {
const labels = [ const labels = [
{ id: 'storage', angle: angles.storage, len: lens.storage }, { id: 'storage', angle: angles.storage, len: lens.storage },
{ id: 'free', angle: angles.free, len: lens.free }, { id: 'free', angle: angles.free, len: lens.free },
{ id: 'other', angle: angles.other, len: lens.other } ...(showOtherCategory.value
? [{ id: 'other', angle: angles.other!, len: lens.other! }]
: [])
] ]
labels.sort((a, b) => a.angle - b.angle) labels.sort((a, b) => a.angle - b.angle)
@@ -269,11 +290,21 @@ const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
} }
} }
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)) const storageLabelPath = computed(() =>
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4)) createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5)) )
const freeLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
)
const otherLabelPath = computed(() =>
createArcPath(
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
'other',
5
)
)
const handleClick = () => isExpanded.value ? collapse() : expand() const handleClick = () => (isExpanded.value ? collapse() : expand())
const applyAnimState = (t: number, opacity: number) => { const applyAnimState = (t: number, opacity: number) => {
const widget = widgetRef.value const widget = widgetRef.value
@@ -296,9 +327,9 @@ const animate = (duration: number, expanding: boolean, onComplete?: () => void)
const tick = (now: number) => { const tick = (now: number) => {
const elapsed = now - startTime const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1) const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
const t = expanding ? eased : 1 - eased const t = expanding ? eased : 1 - eased
applyAnimState(t, t) // opacity follows position applyAnimState(t, t) // opacity follows position
if (progress < 1) { if (progress < 1) {
animationFrame = requestAnimationFrame(tick) animationFrame = requestAnimationFrame(tick)
} else { } else {
+10 -9
View File
@@ -3,9 +3,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { zipName } from '@/utils/fileutil' import { zipName } from '@/utils/fileutil'
const store = useMainStore() const store = useMainStore()
@@ -24,9 +24,9 @@ const status_init = {
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 0, filepos: 0,
status: 'idle', status: 'idle'
} }
store.dprogress = {...status_init} store.dprogress = { ...status_init }
setInterval(() => { setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) { if (Date.now() - store.dprogress.tlast > 3000) {
// Reset // Reset
@@ -34,8 +34,8 @@ setInterval(() => {
store.dprogress.statdur = 1 store.dprogress.statdur = 1
} else { } else {
// Running average by decay // Running average by decay
store.dprogress.statbytes *= .9 store.dprogress.statbytes *= 0.9
store.dprogress.statdur *= .9 store.dprogress.statdur *= 0.9
} }
}, 100) }, 100)
const statReset = () => { const statReset = () => {
@@ -44,10 +44,9 @@ const statReset = () => {
store.dprogress.tlast = store.dprogress.t0 + 1 store.dprogress.tlast = store.dprogress.t0 + 1
} }
const cancelDownloads = () => { const cancelDownloads = () => {
location.reload() // FIXME location.reload() // FIXME
} }
const linkdl = (href: string) => { const linkdl = (href: string) => {
const a = document.createElement('a') const a = document.createElement('a')
a.href = href a.href = href
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
if (e.altKey && 'showDirectoryPicker' in window) { if (e.altKey && 'showDirectoryPicker' in window) {
try { try {
// @ts-ignore // @ts-ignore
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' }) const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle) await filesystemdl(sel, handle)
store.selected.clear() store.selected.clear()
} catch (e) { } catch (e) {
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
// Default: ZIP download // Default: ZIP download
zipdl(sel) zipdl(sel)
} }
</script> </script>
<style scoped> <style scoped>
+25 -4
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="!props.path || documents.length === 0" class="empty-container"> <div v-if="showEmpty" class="empty-container">
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/> <component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p> <p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p> <p v-else-if="!store.connected">No Connection</p>
@@ -11,19 +11,40 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { Cog } from '@/assets/svg' import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
import { computed } from 'vue'
const cog = Cog const cog = Cog
const store = useMainStore() const store = useMainStore()
const props = defineProps<{ const props = defineProps<{
path: string[], path: string[]
documents: Document[], documents: Document[]
}>() }>()
const showEmpty = computed(() => {
const loc = props.path.join('/')
const hasVisibleGhost = store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return g.loc === loc && !store.hiddenPaths.has(full)
})
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
})
</script> </script>
<style scoped> <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);
}
@keyframes rotate { @keyframes rotate {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
+354 -200
View File
@@ -1,85 +1,99 @@
<template> <template>
<table v-if="props.documents.length || editing"> <div class="file-explorer">
<thead> <table v-if="props.documents.length || editing">
<tr> <thead>
<th class="selection"> <tr>
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> <th class="selection">
</th> <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> </th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="menu"></th> <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
</tr> <th class="menu"></th>
</thead>
<tbody>
<tr v-if="editing?.key === 'new'" class="folder">
<td class="selection"></td>
<td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td>
<FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr>
<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>
</thead>
<tr <tbody>
:id="`file-${doc.key}`" <tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }" <td class="selection"></td>
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<input
type="checkbox"
tabindex="-1"
:checked="store.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
"
/>
</td>
<td class="name"> <td class="name">
<template v-if="editing === doc"> <FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<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 :now=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
</td> </td>
<FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr> </tr>
</template> <template v-for="(doc, index) in documents" :key="doc.key">
<tr class="summary" v-if="props.documents.length > 1"> <tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<td colspan="3" class="right">{{props.documents.length}} items</td> <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td> </tr>
<td class="menu"></td>
</tr> <tr
</tbody> :id="`file-${doc.key}`"
</table> :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="store.cursor = store.cursor === doc.key ? doc.key : ''">
<input
type="checkbox"
tabindex="-1"
:checked="store.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
"
/>
</td>
<td class="name">
<template v-if="editing === doc">
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
</template>
<template v-else>
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
</td>
</tr>
</template>
<tr class="summary" v-if="props.documents.length > 1">
<td colspan="3" class="right">{{props.documents.length}} items</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td>
</tr>
</tbody>
</table>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue' import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import { useMainStore } from '@/stores/main'
import { connect, controlUrl } from '@/repositories/WS'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import { useRouter } from 'vue-router' import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
shallowRef,
watch
} from 'vue'
import { useRouter } from 'vue-router'
import FileRenameInput from './FileRenameInput.vue'
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -87,35 +101,133 @@ const props = defineProps<{
}>() }>()
const store = useMainStore() const store = useMainStore()
const router = useRouter() const router = useRouter()
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
const getCursorIndex = () =>
store.cursor
? props.documents.findIndex(doc => doc.key === store.cursor)
: props.documents.length
const getDocElement = (key: string) =>
document.getElementById(`file-${key}`) as HTMLElement | null
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
const select = !!ev?.shiftKey
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 = getCursorIndex()
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? getDocElement(store.cursor) : null
if (select) {
let [begin, end] = moveto >= index ? [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)
}
}
keepCursorVisibleSmooth(tr)
if (moveto === N) {
if (index > moveto) focusBreadcrumb()
else focusHeader()
}
}
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
const docs = props.documents
if (docs.length === 0) return
const scroller =
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
const currentIndex = getCursorIndex()
const currentEl = store.cursor ? getDocElement(store.cursor) : null
const currentCenter = currentEl
? currentEl.getBoundingClientRect().top +
currentEl.getBoundingClientRect().height / 2
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
const targetCenter =
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
let bestIndex = direction > 0 ? docs.length - 1 : 0
let bestDistance = Number.POSITIVE_INFINITY
for (let i = 0; i < docs.length; i++) {
if (
currentIndex !== docs.length &&
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
)
continue
const el = getDocElement(docs[i]!.key)
if (!el) continue
const center =
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
const distance = Math.abs(center - targetCenter)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
markKeyboardFollow()
moveCursorTo(bestIndex, ev)
}
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => { const exitEditing = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
} }
defineExpose({ defineExpose({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() { newFolder() {
console.log("New folder")
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
loc: loc.value, loc: loc.value,
@@ -124,7 +236,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0, allocated: 0
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -139,9 +251,11 @@ defineExpose({
const docs = props.documents const docs = props.documents
if (docs.length > 0) { if (docs.length > 0) {
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => { nextTick(() => {
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null const a = document.querySelector(
`#file-${store.cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus() if (a) a.focus()
}) })
} }
@@ -157,10 +271,33 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
markKeyboardFollow()
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, up(ev: KeyboardEvent) {
down(ev: KeyboardEvent) { this.cursorMove(1, ev) }, markKeyboardFollow()
this.cursorMove(-1, ev)
},
down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(1, ev)
},
pageUp(ev: KeyboardEvent) {
pageMove(-1, ev)
},
pageDown(ev: KeyboardEvent) {
pageMove(1, ev)
},
home(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(0, ev)
},
end(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(props.documents.length - 1, ev)
},
left(ev: KeyboardEvent) { left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root) // Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) { if (props.path.length > 0) {
@@ -168,12 +305,12 @@ defineExpose({
} }
}, },
right(ev: KeyboardEvent) { right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null const a = document.querySelector(
`#file-${store.cursor} a`
) as HTMLAnchorElement | null
if (a) a.click() if (a) a.click()
}, },
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
store.cursor = '' store.cursor = ''
@@ -182,102 +319,115 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index = getCursorIndex()
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? '' moveCursorTo(moveto, ev)
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)
}
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => { const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null const el = document.querySelector(
'.headermain input[type="search"]'
) as HTMLElement | null
if (el) el.focus() if (el) el.focus()
} }
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
} }
let scrolltimer: any = null const keyboardFollowScroll = createKeyboardFollowScroll()
let scrolltr: any = null const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
watchEffect(() => { const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null // Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
if (editing.value) store.cursor = editing.value?.key // stale props - their watchers must not react to global store changes.
if (store.cursor) { let isActive = true
const a = document.querySelector( watch(
`#file-${store.cursor} .name a` () => store.cursor,
) as HTMLAnchorElement | null cursor => {
if (a) a.focus() if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exitEditing()
}
} }
}) )
watchEffect(() => { watch(
if (!props.documents.length && store.cursor && !store.query) { () => store.cursor,
store.cursor = '' cursor => {
focusBreadcrumb() if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(
`#file-${cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus({ preventScroll: true })
}
},
{ flush: 'post' }
)
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
} }
}) )
let nowkey = ref(0) let nowkey = ref(0)
let modifiedTimer: any = null let modifiedTimer: any = null
const updateModified = () => { const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000) nowkey.value = Math.floor(Date.now() / 1000)
} }
onMounted(() => { onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000) updateModified()
modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.focus({ preventScroll: true })
active.focus()
} }
}) })
onUnmounted(() => { clearInterval(modifiedTimer) }) onActivated(() => {
const mkdir = (doc: Doc, name: string) => { isActive = true
const control = connect(controlUrl, { })
open() { onDeactivated(() => {
control.send( isActive = false
JSON.stringify({ if (editing.value) exitEditing()
op: 'mkdir', })
path: `${doc.loc}/${name}` onUnmounted(() => {
}) keyboardFollowScroll.cancel()
) clearInterval(modifiedTimer)
}, })
message(ev: MessageEvent) { const editRoute = (path: string) =>
const msg = JSON.parse(ev.data) '/' +
if ('error' in msg) { path
console.error('Mkdir failed', msg.error.message, msg.error) .split('/')
editing.value = null .map(part => encodeURIComponent(part))
} else { .join('/')
console.log('mkdir', msg)
router.push(doc.urlrouter) const createItem = async (doc: Doc, name: string) => {
}
}
})
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null store.cursor = doc.key
exitEditing()
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res))
if (doc.dir) {
router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) {
console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Create failed')
}
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
const docs = props.documents const docs = props.documents
@@ -355,12 +505,14 @@ const copyImage = async (doc: Doc) => {
if (blob.type !== 'image/png') { if (blob.type !== 'image/png') {
const img = new Image() const img = new Image()
img.src = URL.createObjectURL(blob) img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r) await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth canvas.width = img.naturalWidth
canvas.height = img.naturalHeight canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0) canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png')) const pngBlob = await new Promise<Blob>(r =>
canvas.toBlob(b => r(b!), 'image/png')
)
URL.revokeObjectURL(img.src) URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else { } else {
@@ -373,24 +525,17 @@ const copyImage = async (doc: Doc) => {
} }
} }
const deleteFile = (doc: Doc) => { const deleteFile = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path) store.hideDoc(path)
const control = connect(controlUrl, { try {
message(ev: MessageEvent) { const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
const res = JSON.parse(ev.data) if (!res.ok) throw new Error(await parseErrorMessage(res))
if ('error' in res) { store.showToast(`🗑️ Deleted ${doc.name}`)
console.error('Delete failed', res.error) } catch (err) {
store.unhideDoc(path) console.error('Delete failed', err)
store.showToast(res.error.message || 'Delete failed') store.unhideDoc(path)
} else if (res.status === 'ack') { store.showToast(err instanceof Error ? err.message : 'Delete failed')
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
} }
} }
@@ -398,21 +543,31 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
const items = [ const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) }, { label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }, { label: '🔗 Copy Link', onClick: () => copyLink(doc) }
] ]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) }) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push( items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } }, {
{ label: '🗑 Delete', onClick: () => deleteFile(doc) }, label: ' Rename',
onClick: () => {
editing.value = doc
}
},
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
) )
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items }) ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
</script> </script>
<style scoped> <style scoped>
.file-explorer {
height: 100%;
width: 100%;
}
table { table {
width: 100%; width: 100%;
table-layout: fixed; table-layout: fixed;
height: auto;
} }
thead tr { thead tr {
position: sticky; position: sticky;
@@ -473,6 +628,12 @@ table td {
.name .rename-button { .name .rename-button {
position: absolute; position: absolute;
right: 0; right: 0;
opacity: 0;
visibility: hidden;
}
tbody tr:hover .name .rename-button {
opacity: 1;
visibility: visible;
animation: appear calc(5 * var(--transition-time)) linear; animation: appear calc(5 * var(--transition-time)) linear;
} }
@keyframes appear { @keyframes appear {
@@ -543,12 +704,6 @@ tbody .selection input {
content: '📁'; content: '📁';
font-size: 1.5rem; font-size: 1.5rem;
} }
.empty-container {
padding-top: 3rem;
text-align: center;
font-size: 3rem;
color: var(--accent-color);
}
.folder-change { .folder-change {
margin-left: -.5rem; margin-left: -.5rem;
} }
@@ -559,4 +714,3 @@ tbody .selection input {
color: #888; color: #888;
} }
</style> </style>
@/stores/main
+3 -3
View File
@@ -17,15 +17,15 @@ import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
now: number now: number
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
// Reference props.now to trigger reactivity when time updates // Reference props.now to trigger reactivity when time updates
const modified = computed(() => { const modified = computed(() => {
props.now // trigger reactivity props.now // trigger reactivity
return formatUnixDate(props.doc.mtime) return formatUnixDate(props.doc.mtime)
}) })
+2 -1
View File
@@ -13,7 +13,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue' import { nextTick, onMounted, ref } from 'vue'
const input = ref<HTMLInputElement | null>(null) const input = ref<HTMLInputElement | null>(null)
const name = ref('') const name = ref('')
@@ -60,6 +60,7 @@ input#FileRenameInput {
padding: .75em; padding: .75em;
font-weight: 600; font-weight: 600;
width: auto; width: auto;
text-align: center;
} }
</style> </style>
+4 -4
View File
@@ -13,20 +13,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import SparseIndicator from './SparseIndicator.vue' import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const sizeClass = computed(() => { const sizeClass = computed(() => {
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]! const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
return +unit ? "bytes" : unit return +unit ? 'bytes' : unit
}) })
const tooltipText = computed(() => { const tooltipText = computed(() => {
+418 -135
View File
@@ -1,21 +1,40 @@
<template> <template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key> <template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" /> <GalleryFigure
:doc=doc
:editing="editing === doc ? {rename, exit} : null"
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
@menu="contextMenu($event, doc)"
@rename="onFigureRename(doc)"
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
/>
</template> </template>
</div> </div>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue' import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS' import { useMainStore } from '@/stores/main'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort' import type { SortOrder } from '@/utils/docsort'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
shallowRef,
watch
} from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -23,41 +42,244 @@ const props = defineProps<{
}>() }>()
const store = useMainStore() const store = useMainStore()
const router = useRouter() const router = useRouter()
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null } const exit = () => {
const rename = (doc: Doc, newName: string) => { editing.value = null
}
const onFigureRename = (doc: Doc) => {
editing.value = doc
store.cursor = doc.key
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columnCount = ref(1) const columnCount = ref(1)
const columnWidthPx = ref(240)
const emPx = ref(16)
const aspectByKey = ref<Record<string, number>>({})
const optimalRowHeightPx = (ratios: number[]) => {
const w = Math.max(1, columnWidthPx.value)
const minH = Math.max(1, Math.round(7 * emPx.value))
const maxH = Math.max(minH, Math.round(30 * emPx.value))
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
if (usable.length === 0) return Math.round(15 * emPx.value)
let bestH = Math.round(15 * emPx.value)
let bestScore = -1
for (let h = minH; h <= maxH; h++) {
let score = 0
for (const ar of usable) {
let shownW = w
let shownH = w * ar
if (shownH > h) {
shownH = h
shownW = h / ar
}
// Fill efficiency in the row cell (0..1)
score += (shownW * shownH) / (w * h)
}
if (score > bestScore) {
bestScore = score
bestH = h
}
}
return bestH
}
const setAspect = (key: string, ar: number) => {
if (!Number.isFinite(ar) || ar <= 0) return
if (aspectByKey.value[key] === ar) return
aspectByKey.value = {
...aspectByKey.value,
[key]: ar
}
}
const rowHeightsByKey = computed<Record<string, string>>(() => {
const docs = props.documents
const cols = Math.max(1, columnCount.value)
const byKey = aspectByKey.value
const out: Record<string, string> = {}
const assignRows = (group: Doc[]) => {
for (let start = 0; start < group.length; start += cols) {
const row = group.slice(start, start + cols)
const ratios = row
.filter(doc => doc.previewable)
.map(doc => byKey[doc.key])
.filter((ar): ar is number => ar != null)
const height = `${optimalRowHeightPx(ratios)}px`
for (const doc of row) out[doc.key] = height
}
}
let group: Doc[] = []
for (let i = 0; i < docs.length; i++) {
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
assignRows(group)
group = []
}
group.push(docs[i]!)
}
assignRows(group)
return out
})
// Seed collected ratios from server-provided ar values on docs
const seedFromDocs = () => {
for (const doc of props.documents)
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
}
const onImgLoad = (e: Event) => {
const img = e.target as HTMLImageElement
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
if (!anchor) return
const key = anchor.id.slice('file-'.length)
if (!key) return
setAspect(key, img.naturalHeight / img.naturalWidth)
}
const updateColumns = () => { const updateColumns = () => {
if (!gallery.value) return if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length if (gallery.value.getBoundingClientRect().width <= 0) return
const style = getComputedStyle(gallery.value)
const templates = style.gridTemplateColumns
.split(' ')
.filter(part => !!part && part !== 'none')
columnCount.value = Math.max(1, templates.length)
const first = templates[0]
if (first && first.endsWith('px')) {
const parsed = Number.parseFloat(first)
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
}
const parsedEm = Number.parseFloat(style.fontSize)
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
} }
const columns = computed(() => columnCount.value) const columns = computed(() => columnCount.value)
const getCursorIndex = () =>
store.cursor
? props.documents.findIndex(doc => doc.key === store.cursor)
: props.documents.length
const getDocElement = (key: string) =>
document.getElementById(`file-${key}`) as HTMLElement | null
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
const select = !!ev?.shiftKey
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 = getCursorIndex()
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? getDocElement(store.cursor) : null
if (select) {
let [begin, end] = moveto >= index ? [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)
}
}
keepCursorVisibleSmooth(tr)
if (moveto === N) {
if (index > moveto) focusBreadcrumb()
else focusHeader()
}
}
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
const docs = props.documents
if (docs.length === 0) return
const scroller =
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
const currentIndex = getCursorIndex()
const currentEl = store.cursor ? getDocElement(store.cursor) : null
const currentCenter = currentEl
? currentEl.getBoundingClientRect().top +
currentEl.getBoundingClientRect().height / 2
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
const targetCenter =
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
let bestIndex = direction > 0 ? docs.length - 1 : 0
let bestDistance = Number.POSITIVE_INFINITY
for (let i = 0; i < docs.length; i++) {
if (
currentIndex !== docs.length &&
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
)
continue
const el = getDocElement(docs[i]!.key)
if (!el) continue
const center =
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
const distance = Math.abs(center - targetCenter)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
markKeyboardFollow()
moveCursorTo(bestIndex, ev)
}
defineExpose({ defineExpose({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
@@ -67,7 +289,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0, allocated: 0
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -86,9 +308,11 @@ defineExpose({
const docs = props.documents const docs = props.documents
if (docs.length > 0) { if (docs.length > 0) {
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => { nextTick(() => {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) a.focus() if (a) a.focus()
}) })
} }
@@ -104,15 +328,42 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
markKeyboardFollow()
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) }, up(ev: KeyboardEvent) {
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) }, markKeyboardFollow()
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, this.cursorMove(-columns.value, ev)
right(ev: KeyboardEvent) { this.cursorMove(1, ev) }, },
down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(columns.value, ev)
},
left(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-1, ev)
},
right(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(1, ev)
},
pageUp(ev: KeyboardEvent) {
pageMove(-1, ev)
},
pageDown(ev: KeyboardEvent) {
pageMove(1, ev)
},
home(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(0, ev)
},
end(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(props.documents.length - 1, ev)
},
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
store.cursor = '' store.cursor = ''
@@ -121,11 +372,10 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index = getCursorIndex()
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down) // Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === "ArrowLeft") return if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === "ArrowRight") return if (ev && index === N - 1 && ev.key === 'ArrowRight') return
// Calculate new position // Calculate new position
let moveto let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1 if (index === N) moveto = d > 0 ? 0 : N - 1
@@ -134,99 +384,131 @@ defineExpose({
// Wrapping either end, just land outside the list // Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
} }
store.cursor = docs[moveto]?.key ?? '' moveCursorTo(moveto, ev)
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)
}
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => { const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null const el = document.querySelector(
'.headermain input[type="search"]'
) as HTMLElement | null
if (el) el.focus() if (el) el.focus()
} }
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
} }
let scrolltimer: any = null const keyboardFollowScroll = createKeyboardFollowScroll()
let scrolltr: any = null const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
watchEffect(() => { const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null // Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
if (editing.value) store.cursor = editing.value.key // stale props - their watchers must not react to global store changes.
if (store.cursor) { let isActive = true
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null watch(
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) } () => store.cursor,
cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exit()
}
} }
}) )
watchEffect(() => { watch(
if (!props.documents.length && store.cursor && !store.query) { () => store.cursor,
store.cursor = '' cursor => {
focusBreadcrumb() if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
if (a) {
a.focus({ preventScroll: true })
}
}
},
{ flush: 'post' }
)
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
} }
}) )
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
const attachGalleryObservers = () => {
if (!gallery.value || resizeObserver) return
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
const detachGalleryObservers = () => {
resizeObserver?.disconnect()
resizeObserver = null
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
}
onMounted(() => { onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.focus({ preventScroll: true })
active.focus()
} }
updateColumns() updateColumns()
if (gallery.value) { seedFromDocs()
resizeObserver = new ResizeObserver(updateColumns) attachGalleryObservers()
resizeObserver.observe(gallery.value) })
} onActivated(() => {
isActive = true
nextTick(() => {
updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
isActive = false
detachGalleryObservers()
if (editing.value) exit()
}) })
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() keyboardFollowScroll.cancel()
detachGalleryObservers()
}) })
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, { // Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
open() { watch(() => props.documents, seedFromDocs)
control.send( const editRoute = (path: string) =>
JSON.stringify({ '/' +
op: 'mkdir', path
path: `${doc.loc}/${name}` .split('/')
}) .map(part => encodeURIComponent(part))
) .join('/')
},
message(ev: MessageEvent) { const createItem = async (doc: Doc, name: string) => {
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.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null store.cursor = doc.key
exit()
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res))
if (doc.dir) {
router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) {
console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Create failed')
}
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
const docs = props.documents const docs = props.documents
@@ -294,12 +576,14 @@ const copyImage = async (doc: Doc) => {
if (blob.type !== 'image/png') { if (blob.type !== 'image/png') {
const img = new Image() const img = new Image()
img.src = URL.createObjectURL(blob) img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r) await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth canvas.width = img.naturalWidth
canvas.height = img.naturalHeight canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0) canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png')) const pngBlob = await new Promise<Blob>(r =>
canvas.toBlob(b => r(b!), 'image/png')
)
URL.revokeObjectURL(img.src) URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else { } else {
@@ -312,24 +596,17 @@ const copyImage = async (doc: Doc) => {
} }
} }
const deleteFile = (doc: Doc) => { const deleteFile = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path) store.hideDoc(path)
const control = connect(controlUrl, { try {
message(ev: MessageEvent) { const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
const res = JSON.parse(ev.data) if (!res.ok) throw new Error(await parseErrorMessage(res))
if ('error' in res) { store.showToast(`🗑️ Deleted ${doc.name}`)
console.error('Delete failed', res.error) } catch (err) {
store.unhideDoc(path) console.error('Delete failed', err)
store.showToast(res.error.message || 'Delete failed') store.unhideDoc(path)
} else if (res.status === 'ack') { store.showToast(err instanceof Error ? err.message : 'Delete failed')
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
} }
} }
@@ -337,12 +614,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
const items = [ const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) }, { label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }, { label: '🔗 Copy Link', onClick: () => copyLink(doc) }
] ]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) }) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push( items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } }, {
{ label: '🗑 Delete', onClick: () => deleteFile(doc) }, label: ' Rename',
onClick: () => {
editing.value = doc
}
},
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
) )
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items }) ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
@@ -355,7 +637,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: end; align-items: start;
align-content: start;
} }
.folder-indicator { .folder-indicator {
grid-column: 1 / -1; grid-column: 1 / -1;
+143 -29
View File
@@ -10,45 +10,63 @@
> >
<figure> <figure>
<slot></slot> <slot></slot>
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" /> <MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
<div class="titlespacer"></div> <div class="titlespacer"></div>
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)"> <figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
<template v-if="editing"> <template v-if="editing">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit /> <SelectBox :doc=doc @click="store.cursor = doc.key"/>
<div class="filename-row rename-row">
<div class="rename-wrap">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
</div>
</div>
<div class=namespacer></div>
</template> </template>
<template v-else> <template v-else>
<SelectBox :doc=doc @click="store.cursor = doc.key"/> <SelectBox :doc=doc @click="store.cursor = doc.key"/>
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span> <div class="filename-row">
<span class="filename-group">
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
</span>
<button class="rename-btn" @click="emit('rename')" title="Rename"></button>
</div>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
</figcaption> </figcaption>
</figure> </figure>
<CursorTooltip ref="tooltip" :text="tooltipText"> <CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div> <div class="tooltip-name">{{ snap.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div> <div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div> <div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
</CursorTooltip> </CursorTooltip>
</a> </a>
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { formatSize } from '@/utils'
import MediaPreview from '@/components/MediaPreview.vue' import MediaPreview from '@/components/MediaPreview.vue'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue' import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore() const store = useMainStore()
const router = useRouter()
type EditingProp = { type EditingProp = {
rename: (name: string) => void; rename: (doc: Doc, newName: string) => void
exit: () => void; exit: () => void
} }
const props = defineProps<{ const props = defineProps<{
doc: Doc, doc: Doc
editing?: EditingProp, editing?: EditingProp
}>()
const emit = defineEmits<{
(e: 'rename'): void
(e: 'menu', ev: MouseEvent): void
}>() }>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
@@ -60,8 +78,27 @@ const sparseText = computed(() => {
return `${formatSize(allocated)} allocated of ${formatSize(size)}` return `${formatSize(allocated)} allocated of ${formatSize(size)}`
}) })
// Single subscription to docVersion; all doc-derived values come from here.
// This is needed because Doc instances are non-reactive plain objects, so
// mutating doc.name alone won't invalidate computed caches.
const snap = computed(() => {
void store.docVersion
const { name, ext } = props.doc
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
return {
name,
ext,
displayName: base.replace(/[_.]+/g, ' ')
}
})
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) {
ev.preventDefault()
} else if (props.doc.text) {
ev.preventDefault()
router.push(props.doc.editurl.replace('/#', ''))
}
store.cursor = props.doc.key store.cursor = props.doc.key
} }
</script> </script>
@@ -81,8 +118,78 @@ const onclick = (ev: Event) => {
.after-name { .after-name {
margin-left: 0.3em; margin-left: 0.3em;
} }
.filename-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0;
flex: 0 1 auto;
min-width: 0;
position: relative;
overflow: visible;
max-width: calc(100% - 4.5em);
}
.filename-row::after {
content: '';
position: absolute;
left: 100%;
top: 0;
width: 1.4em;
height: 100%;
}
.filename-group {
display: inline-flex;
align-items: baseline;
min-width: 0;
max-width: 100%;
}
.filename {
cursor: default;
padding: .5em 0;
color: #fff;
font-size: 0.8em;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 0 1 auto;
min-width: 0;
}
.file-ext {
color: rgba(255, 255, 255, 0.8);
font-size: 0.8em;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
padding: 0 .15em 0 0;
white-space: nowrap;
flex: 0 0 auto;
}
.rename-btn {
position: absolute;
left: 100%;
top: 50%;
transform: translate(0.2em, -50%);
z-index: 2;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.8em;
line-height: 1;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.12s ease;
}
.filename-row:hover .rename-btn {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
figure { figure {
max-height: 15em; height: var(--gallery-figure-height, 15em);
max-height: var(--gallery-figure-height, 15em);
position: relative; position: relative;
border-radius: .5em; border-radius: .5em;
overflow: hidden; overflow: hidden;
@@ -92,12 +199,13 @@ figure {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
transition: height 0.4s ease, max-height 0.4s ease;
} }
figure > article { figure > article {
flex: 0 0 auto; flex: 0 0 auto;
} }
figure :deep(.video-container) { figure :deep(.video-container) {
height: 15em; height: var(--gallery-figure-height, 15em);
} }
.titlespacer { .titlespacer {
flex-shrink: 100000; flex-shrink: 100000;
@@ -114,9 +222,9 @@ figcaption {
width: 100%; width: 100%;
} }
figcaption input[type='checkbox'] { figcaption input[type='checkbox'] {
width: 1.5em; width: 1.1em;
height: 1.5em; height: 1.1em;
margin: .25em 0 .25em .25em; margin: .25em .4em .25em .35em;
opacity: 0; opacity: 0;
flex-shrink: 0; flex-shrink: 0;
transition: opacity var(--transition-time) ease-in-out; transition: opacity var(--transition-time) ease-in-out;
@@ -124,17 +232,10 @@ figcaption input[type='checkbox'] {
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] { figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
opacity: 1; opacity: 1;
} }
figcaption span { .cursor .filename {
cursor: default; color: var(--accent-color);
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 { .cursor .file-ext {
color: var(--accent-color); color: var(--accent-color);
} }
figcaption .namespacer { figcaption .namespacer {
@@ -142,4 +243,17 @@ figcaption .namespacer {
height: 2em; height: 2em;
width: 2em; width: 2em;
} }
.rename-wrap {
font-size: 0.8em;
width: auto;
min-width: 0;
max-width: 100%;
}
.rename-row {
max-width: calc(100% - 4.5em);
}
.rename-wrap :deep(#FileRenameInput) {
min-width: 0;
max-width: 100%;
}
</style> </style>
+141 -34
View File
@@ -1,24 +1,43 @@
<template> <template>
<nav class="headermain buttons"> <nav class="headermain buttons">
<UploadButton :path="props.path" /> <template v-if="!props.editorMode">
<SvgButton <UploadButton :path="props.path" />
name="create-folder" <SvgButton
tooltip="New folder" name="create-file"
@click="() => { store.fileExplorer!.newFolder() }" tooltip="New file"
/> @click="() => { store.fileExplorer!.newFile() }"
<div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group">
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
@keydown.escape="clearSearch"
/> />
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span> <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" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
@keydown.escape="clearSearch"
/>
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
</div>
</template>
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
<span class="sort-label">Order</span>
<span class="keycap">1</span>
<span class="keycap">2</span>
<span class="keycap">3</span>
</div> </div>
<SvgButton
v-if="props.editorMode"
name="disk"
tooltip="Save (Ctrl/Cmd+S)"
@click="store.editorSave?.()"
/>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" /> <DiskSpace v-if="store.space.disk" />
<SvgButton name="cog" @click="settingsMenu" /> <SvgButton name="cog" @click="settingsMenu" />
@@ -26,24 +45,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { resumeWatching } from '@/repositories/WS'
import router from '@/router'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth' import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia' import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS' import { computed, onMounted, onUnmounted, ref } from 'vue'
import router from '@/router';
import DiskSpace from './DiskSpace.vue' import DiskSpace from './DiskSpace.vue'
const store = useMainStore() const store = useMainStore()
const ssoStore = useSsoAuthStore() const ssoStore = useSsoAuthStore()
const search = ref<HTMLInputElement | null>() const search = ref<HTMLInputElement | null>()
const textInputFocused = ref(false)
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
editorMode?: boolean
}>() }>()
const isInputElement = (el: Element | null): boolean => {
if (!el || !(el instanceof HTMLElement)) return false
return el instanceof HTMLInputElement
}
const updateTextInputFocused = () => {
textInputFocused.value = isInputElement(document.activeElement)
}
const showSortHints = computed(() => !textInputFocused.value)
const clearSearch = (ev: Event) => { const clearSearch = (ev: Event) => {
const input = search.value const input = search.value
if (input) { if (input) {
@@ -78,7 +110,7 @@ const updateSearch = (ev: Event) => {
pendingRouteUpdate = null pendingRouteUpdate = null
let p = loc let p = loc
p = p ? `/${p}` : '' p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/') const url = q ? `${p}//${q}` : p || '/'
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Use replace to avoid building up history for each keystroke // Use replace to avoid building up history for each keystroke
router.replace(u) router.replace(u)
@@ -96,41 +128,86 @@ const settingsMenu = (e: Event) => {
if (ssoStore.isExternalAuth && store.user.isLoggedIn) { if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ items.push({
label: '👤 ' + (store.user.username || 'User Account'), label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' } onClick: () => {
window.location.href = '/auth/'
}
}) })
} }
// Only show password change for non-SSO users // Only show password change for non-SSO users
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) { if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }}) items.push({
label: '🔑 Change Password',
onClick: () => {
store.dialog = 'settings'
}
})
}
if (store.user.isLoggedIn) {
items.push({
label: '🔑 API Tokens',
onClick: () => {
store.dialog = 'tokens'
}
})
} }
if (store.user.privileged) { if (store.user.privileged) {
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }}) items.push({
label: '⚙️ Admin Settings',
onClick: () => {
store.dialog = 'usermgmt'
}
})
} }
if (store.user.isLoggedIn) { if (store.user.isLoggedIn) {
items.push({ label: '🚪 Logout', onClick: () => store.logout() }) items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (store.server.public) { } else if (store.server.public) {
// Show login option only in public mode (non-public modes trigger auth automatically) // Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => { items.push({
try { label: '🔐 Login',
await showAuthIframe('/auth/restricted/#theme=light') onClick: async () => {
resumeWatching() try {
} catch (e) { await showAuthIframe('/auth/restricted/#theme=light')
console.log('Login cancelled') resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
} }
}}) })
} }
items.push({
label: '️ About Cista...',
onClick: () => {
store.dialog = 'about'
}
})
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom, x: e.target.getBoundingClientRect().right,
items, // @ts-ignore
y: e.target.getBoundingClientRect().bottom,
items
}) })
} }
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
clearSearch, clearSearch
})
onMounted(() => {
updateTextInputFocused()
window.addEventListener('focusin', updateTextInputFocused)
window.addEventListener('focusout', updateTextInputFocused)
})
onUnmounted(() => {
window.removeEventListener('focusin', updateTextInputFocused)
window.removeEventListener('focusout', updateTextInputFocused)
}) })
</script> </script>
@@ -213,4 +290,34 @@ defineExpose({
display: block; display: block;
} }
} }
.sort-hints {
display: none;
align-items: center;
gap: 0.25em;
margin-left: 0.3em;
white-space: nowrap;
}
.sort-label {
margin-right: 0.2em;
font-family: system-ui, sans-serif;
font-size: 1em;
font-weight: 700;
color: #ccc;
}
.keycap {
font-family: system-ui, sans-serif;
font-size: 1em;
font-weight: 700;
color: #333;
background: #ccc;
border: 1px solid #999;
border-radius: 0.3em;
padding: 0 0.45em;
line-height: 1.4;
}
@media screen and (min-width: 800px) {
.sort-hints {
display: flex;
}
}
</style> </style>
+198 -40
View File
@@ -1,9 +1,39 @@
<template> <template>
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt=""> <div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
<img v-else-if=doc.img :src=doc.url alt=""> <span
v-show="activeImageLoading"
class="file icon"
:class="[`ext-${doc.ext}`, 'loading-pulse']"
:style="loadingPulseStyle"
></span>
<img
v-if="showPreviewImage"
:src="previewSrc"
alt=""
:class="{ ready: !previewImageLoading }"
@load="onPreviewImageLoad"
@error="onPreviewImageError"
>
<img
v-else
:src="doc.url"
alt=""
:class="{ ready: !nativeImageLoading }"
@load="onNativeImageLoad"
@error="onNativeImageError"
>
</div>
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
<span
class="file icon"
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
:style="loadingPulseStyle"
></span>
</div>
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<div v-else-if=video() class="video-container"> <div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<video v-else ref=vid :src=doc.url preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<div class="play-overlay"><PlayIcon /></div> <div class="play-overlay"><PlayIcon /></div>
</div> </div>
<div v-else-if=audio() class="audio icon"> <div v-else-if=audio() class="audio icon">
@@ -13,19 +43,71 @@
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span> <span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
import { Play as PlayIcon } from '@/assets/svg' import { Play as PlayIcon } from '@/assets/svg'
import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { computed, ref, watch } from 'vue'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value) const media = computed(() => aud.value || vid.value)
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
quality: string quality: string
}>() }>()
const previewImageFailed = ref(false)
const nativeImageFailed = ref(false)
const previewImageLoading = ref(true)
const nativeImageLoading = ref(true)
const previewSrc = computed(() =>
props.doc.previewurl
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
: ''
)
const showPreviewImage = computed(
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
)
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
const activeImageLoading = computed(() =>
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
)
const previewLoadFailed = computed(
() => previewImageFailed.value || nativeImageFailed.value
)
const loadingPulseDelayMs = computed(() => {
let hash = 0
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
return hash % 1800
})
const loadingPulseStyle = computed(() => ({
animationDelay: `${-loadingPulseDelayMs.value}ms`
}))
const onPreviewImageLoad = () => {
previewImageLoading.value = false
}
const onPreviewImageError = () => {
previewImageLoading.value = false
previewImageFailed.value = true
}
const onNativeImageLoad = () => {
nativeImageLoading.value = false
}
const onNativeImageError = () => {
nativeImageLoading.value = false
nativeImageFailed.value = true
}
watch(
() => props.doc.key,
() => {
previewImageFailed.value = false
nativeImageFailed.value = false
previewImageLoading.value = true
nativeImageLoading.value = true
}
)
const onplay = () => { const onplay = () => {
if (!media.value) return if (!media.value) return
@@ -37,11 +119,21 @@ const onpaused = () => {
media.value.controls = false media.value.controls = false
media.value.removeAttribute('data-playing') media.value.removeAttribute('data-playing')
} }
const applyPoster = (el: HTMLVideoElement) => {
if (props.doc.complete) {
el.poster = previewSrc.value
} else {
el.removeAttribute('poster')
}
}
let fscurrent: HTMLVideoElement | null = null let fscurrent: HTMLVideoElement | null = null
const next = () => { const next = () => {
if (!media.value) return if (!media.value) return
media.value.load() // Restore poster media.value.load() // Restore poster
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[] const medias = Array.from(document.querySelectorAll('video, audio')) as (
| HTMLAudioElement
| HTMLVideoElement
)[]
if (medias.length === 0) return if (medias.length === 0) return
let el: HTMLAudioElement | HTMLVideoElement | null = null let el: HTMLAudioElement | HTMLVideoElement | null = null
for (const i in medias) { for (const i in medias) {
@@ -51,28 +143,32 @@ const next = () => {
} }
} }
if (!el) return if (!el) return
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) { if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
// Fullscreen needs to use the current video element for the next video // Fullscreen needs to use the current video element for the next video
// because we are not allowed to fullscreen the next one. // because we are not allowed to fullscreen the next one.
// FIXME: Write our own player to avoid this problem... // FIXME: Write our own player to avoid this problem...
const elem = media.value as HTMLVideoElement const elem = media.value as HTMLVideoElement
const playing = el as HTMLVideoElement const playing = el as HTMLVideoElement
if (elem === playing) { if (elem === playing) {
playing.play() // Only one video, just replay playing.play() // Only one video, just replay
return return
} }
if (!fscurrent) { if (!fscurrent) {
elem.addEventListener('fullscreenchange', ev => { elem.addEventListener(
if (!fscurrent) return 'fullscreenchange',
// Restore the original video element and continue with the one that was playing ev => {
fscurrent.currentTime = elem.currentTime if (!fscurrent) return
fscurrent.click() // Restore the original video element and continue with the one that was playing
if (!elem.paused) fscurrent.play() fscurrent.currentTime = elem.currentTime
fscurrent = null fscurrent.click()
elem.src = props.doc.url if (!elem.paused) fscurrent.play()
elem.poster = poster.value fscurrent = null
onpaused() elem.src = props.doc.url
}, {once: true}) applyPoster(elem)
onpaused()
},
{ once: true }
)
} }
fscurrent = playing fscurrent = playing
elem.src = playing.src elem.src = playing.src
@@ -88,7 +184,10 @@ defineExpose({
if (!media.value) return false if (!media.value) return false
if (media.value.paused) { if (media.value.paused) {
media.value.play() media.value.play()
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) { for (const el of Array.from(document.querySelectorAll('video, audio')) as (
| HTMLAudioElement
| HTMLVideoElement
)[]) {
if (el === media.value) continue if (el === media.value) continue
el.pause() el.pause()
} }
@@ -97,18 +196,25 @@ defineExpose({
} }
return true return true
}, },
media, media
}) })
const video = () => props.doc.video
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext) const audio = () => props.doc.audio
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext) const archive = () => props.doc.archive
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext) const docs = () => props.doc.document
const preview = () => ( // image = requires server-side preview (browsers cannot display it natively)
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) || // img = browser-viewable image that can be used directly in an <img> tag
props.doc.size > 500000 && const image = () => props.doc.image
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext) const print = () => props.doc.print
) const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
const preview = () => {
const store = useMainStore()
return (
!(store.server.office_previews === false && docs()) &&
(image() || print() || (props.doc.img && props.doc.size > 500000))
)
}
</script> </script>
<style scoped> <style scoped>
@@ -120,6 +226,59 @@ img, embed, .icon, audio, video {
max-height: 100%; max-height: 100%;
border-radius: calc(.5em / 8); border-radius: calc(.5em / 8);
} }
.preview-progress {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 50%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
}
.preview-progress .icon {
opacity: 0.9;
}
.preview-image-shell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
.preview-image-shell img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
object-fit: contain;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.preview-image-shell img.ready {
opacity: 1;
}
.loading-pulse {
animation: media-preview-pulse 1.8s ease-in-out infinite;
}
@keyframes media-preview-pulse {
0% {
transform: scale(1);
opacity: 0.86;
}
50% {
transform: scale(1.04);
opacity: 0.98;
}
100% {
transform: scale(1);
opacity: 0.86;
}
}
.folder::before { .folder::before {
content: '📁'; content: '📁';
} }
@@ -163,21 +322,20 @@ img, embed, .icon, audio, video {
figure.cursor .icon { figure.cursor .icon {
filter: brightness(1); filter: brightness(1);
} }
img::before {
/* broken image */
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
.video-container { .video-container {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 50%; min-width: 50%;
min-height: 6em;
aspect-ratio: 16 / 9;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.video-container.pending {
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
}
.video-container video { .video-container video {
width: 100%; width: 100%;
height: 100%; height: 100%;
+68 -6
View File
@@ -13,27 +13,66 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { nextTick, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
const overlay = ref<HTMLDivElement | null>(null) const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null) const dialog = ref<HTMLDivElement | null>(null)
const store = useMainStore() const store = useMainStore()
let backdropHeld = false
const ensureGlobalBackdropStyles = () => {
if (typeof document === 'undefined') return
if (document.getElementById('paskia-dialog')) return
const style = document.createElement('style')
style.id = 'paskia-dialog'
style.textContent = `body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#paskia-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
`
document.head.insertBefore(style, document.head.firstChild)
}
const close = () => { const close = () => {
store.dialog = '' store.dialog = ''
releaseGlobalBackdrop()
} }
const props = defineProps<{ const props = defineProps<{
title: string, title: string
name: typeof store.dialog, name: typeof store.dialog
}>() }>()
const show = () => { const show = () => {
store.dialog = props.name store.dialog = props.name
holdGlobalBackdrop()
nextTick(() => { nextTick(() => {
overlay.value?.focus() overlay.value?.focus()
const input = dialog.value?.querySelector('input') const input = dialog.value?.querySelector('input')
@@ -41,6 +80,29 @@ const show = () => {
}) })
} }
defineExpose({ show, close }) defineExpose({ show, close })
watch(
() => store.dialog === props.name,
isOpen => {
if (isOpen && !backdropHeld) {
ensureGlobalBackdropStyles()
holdGlobalBackdrop()
backdropHeld = true
} else if (!isOpen && backdropHeld) {
releaseGlobalBackdrop()
backdropHeld = false
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (backdropHeld) {
releaseGlobalBackdrop()
backdropHeld = false
}
})
watchEffect(() => { watchEffect(() => {
if (overlay.value) { if (overlay.value) {
overlay.value.focus() overlay.value.focus()
+2 -3
View File
@@ -10,13 +10,12 @@
> >
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
}>() }>()
const store = useMainStore() const store = useMainStore()
</script> </script>
+85 -34
View File
@@ -15,6 +15,11 @@
</div> </div>
<span class="select-size">{{ selectionDisplay.size }}</span> <span class="select-size">{{ selectionDisplay.size }}</span>
<DownloadButton /> <DownloadButton />
<SvgButton
name="link"
tooltip="Copy share link (Alt-click for read/write)"
@click="copyShareLink"
/>
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" /> <SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" /> <SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" /> <SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
@@ -24,17 +29,19 @@
@mouseenter="unselectTooltip?.startHover" @mouseenter="unselectTooltip?.startHover"
@mousemove="unselectTooltip?.updatePosition" @mousemove="unselectTooltip?.updatePosition"
@mouseleave="unselectTooltip?.endHover" @mouseleave="unselectTooltip?.endHover"
> selection</button> > deselect</button>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS' import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main' import type { ISimpleError } from '@/repositories/Client'
import { computed, ref } from 'vue' import { createShareToken } from '@/repositories/User'
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
import router from '@/router' import router from '@/router'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
@@ -49,6 +56,22 @@ const navigateTo = (path: string) => {
router.push('/' + path) router.push('/' + path)
} }
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// Truncate long names to reasonable length // Truncate long names to reasonable length
const truncateName = (name: string, maxLen = 20): string => { const truncateName = (name: string, maxLen = 20): string => {
if (name.length <= maxLen) return name if (name.length <= maxLen) return name
@@ -98,7 +121,7 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
if (count === 1) { if (count === 1) {
displayName = truncateName(names[0]!) displayName = truncateName(names[0]!)
} else { } else {
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root') const folderName = loc ? loc.split('/').pop()! : store.server.name || 'Root'
displayName = `${truncateName(folderName)} (${count})` displayName = `${truncateName(folderName)} (${count})`
} }
return { return {
@@ -115,46 +138,74 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
} }
}) })
const op = (opName: string, dst?: string) => { const op = async (opName: string, dst?: string) => {
const sel = store.selectedFiles const sel = store.selectedFiles
const keys = sel.keys
const paths = sel.keys.map(key => { const paths = sel.keys.map(key => {
const doc = sel.docs[key]! const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
}) })
const msg = {
op: opName,
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
// Hide items being deleted or moved (optimistic update) // Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') { if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path) for (const path of paths) store.hideDoc(path)
} }
const control = connect(controlUrl, {
message(ev: MessageEvent) { try {
const res = JSON.parse(ev.data) if (opName === 'rm') {
if ('error' in res) { for (const path of paths) {
console.error('Control socket error', msg, res.error) const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
store.error = res.error.message if (!res.ok) throw new Error(await parseErrorMessage(res))
// Restore hidden items on error }
if (opName === 'rm' || opName === 'mv') { } else if (opName === 'mv' || opName === 'cp') {
for (const path of paths) store.unhideDoc(path) if (keys.length === 0) throw new Error('No selected files')
} const dstUrl = dst ? filesUrl(dst) : '/files/'
return const query = `${opName}=${keys.join('+')}`
} else if (res.status === 'ack') { const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
console.log('Control ack OK', res) if (!res.ok) throw new Error(await parseErrorMessage(res))
control.close() } else {
store.selected.clear() throw new Error(`Unsupported operation: ${opName}`)
return }
} else console.log('Unknown control response', msg, res)
store.selected.clear()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('REST file operation failed', opName, err)
store.error = message
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
} }
})
control.onopen = () => {
control.send(JSON.stringify(msg))
} }
} }
const copyShareLink = async (ev: MouseEvent) => {
const mode: 'ro' | 'rw' = ev.altKey ? 'rw' : 'ro'
const sel = store.selectedFiles
const paths = sel.keys
.map(key => {
const doc = sel.docs[key]
if (!doc) return ''
if (doc.loc === '/' || !doc.loc) return doc.name
return `${doc.loc}/${doc.name}`
})
.filter(Boolean)
if (!paths.length) {
store.showToast('No selected files')
return
}
try {
const token = await createShareToken(paths, mode)
await navigator.clipboard.writeText(token.url)
store.showToast(
mode === 'rw' ? 'Copied read/write share link' : 'Copied share link'
)
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create share link')
}
}
</script> </script>
<style> <style>
+2 -2
View File
@@ -44,10 +44,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from 'vue'
import { changePassword } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { changePassword } from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { reactive, ref } from 'vue'
const confirmLoading = ref<boolean>(false) const confirmLoading = ref<boolean>(false)
const store = useMainStore() const store = useMainStore()
+1 -1
View File
@@ -13,7 +13,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { icons, type IconName } from '@/assets/svg' import { type IconName, icons } from '@/assets/svg'
import { ref } from 'vue' import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
+8 -7
View File
@@ -19,7 +19,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
defineEmits(['cancel']) defineEmits(['cancel'])
@@ -38,16 +38,17 @@ const props = defineProps<{
} }
}>() }>()
const percent = computed(() => props.status.xfer / props.status.total * 100) const percent = computed(() => (props.status.xfer / props.status.total) * 100)
const speed = computed(() => { const speed = computed(() => {
let s = props.status.statbytes / props.status.statdur / 1e3 let s = props.status.statbytes / props.status.statdur / 1e3
const tsince = (Date.now() - props.status.tlast) / 1e3 const tsince = (Date.now() - props.status.tlast) / 1e3
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled 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 if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed" return s // "Current speed"
}) })
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled') const speeddisp = computed(() =>
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
)
</script> </script>
<style scoped> <style scoped>
+372 -89
View File
@@ -8,12 +8,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { collator } from '@/utils'; import { getDocuments } from '@/stores/documentStore'
import { onMounted, onUnmounted, reactive, ref } from 'vue' import { useMainStore } from '@/stores/main'
import { collator, formatSize } from '@/utils'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
@@ -29,6 +28,23 @@ type CloudFile = {
cloudName: string cloudName: string
cloudPos: number cloudPos: number
} }
type UploadRange = {
name: string
size: number
start: number
end: number
}
type InflightBlock = {
name: string
start: number
end: number
startedAt: number
}
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
function pasteHandler(event: ClipboardEvent) { function pasteHandler(event: ClipboardEvent) {
const items = Array.from(event.clipboardData?.items ?? []) const items = Array.from(event.clipboardData?.items ?? [])
const infiles = [] as File[] const infiles = [] as File[]
@@ -46,7 +62,9 @@ function pasteHandler(event: ClipboardEvent) {
if (infiles.length || dirs.length) { if (infiles.length || dirs.length) {
event.preventDefault() event.preventDefault()
uploadFiles(infiles) uploadFiles(infiles)
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`) const base = props.path!.join('/')
for (const entry of dirs)
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
} }
} }
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => { const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
@@ -56,8 +74,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
for (const entry of entries) { for (const entry of entries) {
const cloudName = `${loc}/${entry.name}` const cloudName = `${loc}/${entry.name}`
if (entry.isFile) { if (entry.isFile) {
const file = await new Promise(resolve => entry.file(resolve)) as File const file = (await new Promise(resolve => entry.file(resolve))) as File
cloudfiles.push({file, cloudName, cloudPos: 0}) cloudfiles.push({ file, cloudName, cloudPos: 0 })
} else if (entry.isDirectory) { } else if (entry.isDirectory) {
await pasteDirectory(entry, cloudName) await pasteDirectory(entry, cloudName)
} }
@@ -68,7 +86,9 @@ function uploadHandler(event: Event) {
event.preventDefault() event.preventDefault()
// @ts-ignore // @ts-ignore
const input = event.target as HTMLInputElement | null const input = event.target as HTMLInputElement | null
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[] const infiles = Array.from(
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
) as File[]
if (input) input.value = '' if (input) input.value = ''
if (infiles.length) uploadFiles(infiles) if (infiles.length) uploadFiles(infiles)
} }
@@ -82,8 +102,8 @@ const uploadFiles = (infiles: File[]) => {
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? '' if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
files.push({ files.push({
file, file,
cloudName: loc + '/' + relPath, cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
cloudPos: 0, cloudPos: 0
}) })
} }
uploadCloudFiles(files) uploadCloudFiles(files)
@@ -97,6 +117,28 @@ const uploadCloudFiles = (files: CloudFile[]) => {
} }
if (!files.length) return if (!files.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Space check: reject the whole batch if there isn't enough free space.
const batchTotal = files.reduce((sum, f) => sum + f.file.size, 0)
const allDocs = getDocuments()
const docByPath = new Map<string, Doc>()
for (const d of allDocs) {
const path = d.loc ? `${d.loc}/${d.name}` : d.name
docByPath.set(path, d)
}
let overwriteSize = 0
for (const f of files) {
const existing = docByPath.get(f.cloudName)
if (existing && !existing.dir) overwriteSize += existing.size
}
const netNeed = batchTotal - overwriteSize
if (store.space.free < netNeed + UPLOAD_MARGIN_BYTES) {
store.showToast(
`Not enough free space (need ${formatSize(netNeed + UPLOAD_MARGIN_BYTES)}, have ${formatSize(store.space.free)})`
)
return
}
// Optimistic update: ghost folders and files // Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const docs = getDocuments() const docs = getDocuments()
@@ -115,13 +157,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/') const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) { if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true })) store.addGhost(
new Doc({
loc: parts.slice(0, i).join('/'),
name: parts[i],
key: crypto.randomUUID(),
size: 0,
allocated: 0,
mtime: now,
dir: true
})
)
added.add(folderPath) added.add(folderPath)
} }
} }
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible) // Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName) const existing = byPath.get(f.cloudName)
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false })) if (!existing)
store.addGhost(
new Doc({
loc,
name,
key: crypto.randomUUID(),
size: f.file.size,
allocated: 0,
mtime: now,
dir: false
})
)
} }
// @ts-ignore // @ts-ignore
upqueue = [...upqueue, ...files] upqueue = [...upqueue, ...files]
@@ -130,7 +193,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
} }
const cancelUploads = () => { const cancelUploads = () => {
uploadRunId += 1
upqueue = [] upqueue = []
blockQueue = []
inflightBlocks.clear()
uploadedBytes.clear()
store.uprogress.status = 'idle'
statReset() statReset()
} }
@@ -148,30 +216,106 @@ const uprogress_init = {
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 0, filepos: 0,
status: 'idle', status: 'idle'
} }
store.uprogress = {...uprogress_init} store.uprogress = { ...uprogress_init }
// Track uploaded bytes for each file to handle out-of-order uploads
const uploadedBytes = new Map<string, Set<number>>()
const inflightBlocks = new Map<string, InflightBlock>()
let smoothedBlockMs = 1500
let lastProgressTick = Date.now()
let lastVisualUploaded = 0
const inflightKey = (name: string, start: number) => `${name}:${start}`
const completedUploadedBytes = (name: string, size: number) => {
const uploaded = uploadedBytes.get(name)
if (!uploaded) return 0
const blockSize = UPLOAD_BLOCK_SIZE
let total = 0
for (let i = 0; i < size; i += blockSize) {
if (uploaded.has(i)) total += Math.min(blockSize, size - i)
}
return total
}
const simulatedInflightBytes = (name: string, now: number) => {
let total = 0
for (const block of inflightBlocks.values()) {
if (block.name !== name) continue
const size = block.end - block.start
const elapsed = Math.max(0, now - block.startedAt)
const fraction = Math.min(0.98, elapsed / Math.max(200, smoothedBlockMs))
total += size * fraction
}
return total
}
const refreshProgress = (now: number) => {
const name = store.uprogress.filename
const size = store.uprogress.filesize
if (!name || !size) {
lastProgressTick = now
return 0
}
const completed = completedUploadedBytes(name, size)
const estimated = simulatedInflightBytes(name, now)
const visualUploaded = Math.min(size, Math.round(completed + estimated))
const delta = Math.max(0, visualUploaded - lastVisualUploaded)
const dt = Math.max(1, now - lastProgressTick)
store.uprogress.filepos = visualUploaded
store.uprogress.xfer = store.uprogress.filestart + visualUploaded
if (delta > 0) {
store.uprogress.statbytes += delta
store.uprogress.statdur += dt
store.uprogress.tlast = now
}
lastVisualUploaded = visualUploaded
lastProgressTick = now
return delta
}
setInterval(() => { setInterval(() => {
if (Date.now() - store.uprogress.tlast > 3000) { const now = Date.now()
// Reset const delta = refreshProgress(now)
if (delta > 0) return
if (now - store.uprogress.tlast > 3000) {
store.uprogress.statbytes = 0 store.uprogress.statbytes = 0
store.uprogress.statdur = 1 store.uprogress.statdur = 1
} else { } else {
// Running average by decay store.uprogress.statbytes *= 0.95
store.uprogress.statbytes *= .9 store.uprogress.statdur *= 0.95
store.uprogress.statdur *= .9
} }
}, 100) }, 100)
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
if (name !== store.uprogress.filename) return // If stats have been reset const statUpdate = ({ name, size, start, end }: UploadRange) => {
const now = Date.now() if (name !== store.uprogress.filename) return // If stats have been reset
store.uprogress.xfer = store.uprogress.filestart + end
store.uprogress.filepos = end // Track which bytes have been uploaded (using start to end range)
store.uprogress.statbytes += end - start if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
store.uprogress.statdur += now - store.uprogress.tlast const uploaded = uploadedBytes.get(name)!
store.uprogress.tlast = now const blockSize = UPLOAD_BLOCK_SIZE
// File finished?
if (end === size) { // Mark all bytes in this block as uploaded
for (let i = start; i < end; i += blockSize) {
uploaded.add(i)
}
refreshProgress(Date.now())
// Check if file is fully uploaded by examining the block queue
const currentUpload = blockQueue[0]
if (!currentUpload) return
if (
currentUpload.file.cloudName === name &&
currentUpload.completed >= currentUpload.blocks.length
) {
// All blocks for this file have been uploaded
uploadedBytes.delete(name) // Clean up tracking
store.uprogress.filestart += size store.uprogress.filestart += size
statNextFile() statNextFile()
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset() if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
@@ -183,11 +327,15 @@ const statNextFile = () => {
store.uprogress.filepos = 0 store.uprogress.filepos = 0
store.uprogress.filesize = f.file.size store.uprogress.filesize = f.file.size
store.uprogress.filename = f.cloudName store.uprogress.filename = f.cloudName
lastVisualUploaded = 0
lastProgressTick = Date.now()
} }
const statReset = () => { const statReset = () => {
Object.assign(store.uprogress, uprogress_init) Object.assign(store.uprogress, uprogress_init)
store.uprogress.t0 = Date.now() store.uprogress.t0 = Date.now()
store.uprogress.tlast = store.uprogress.t0 + 1 store.uprogress.tlast = store.uprogress.t0 + 1
lastVisualUploaded = 0
lastProgressTick = store.uprogress.t0
} }
const statsAdd = (f: CloudFile[]) => { const statsAdd = (f: CloudFile[]) => {
if (store.uprogress.files.length === 0) statReset() if (store.uprogress.files.length === 0) statReset()
@@ -197,69 +345,204 @@ const statsAdd = (f: CloudFile[]) => {
statNextFile() statNextFile()
} }
let upqueue = [] as CloudFile[] let upqueue = [] as CloudFile[]
const MAX_PARALLEL_REQUESTS = 4
const RETRY_DELAY_MS = 400
// TODO: Rewrite as WebSocket class // Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
const WSCreate = async () => await new Promise<WebSocket>(resolve => { const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
const ws = connect(uploadUrl, { const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
open(ev: Event) { resolve(ws) }, const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
error(ev: Event) { const FINAL_BLOCKS_COUNT = 2
console.error('Upload socket error', ev)
store.error = 'Upload socket error' const fileSize = file.file.size
}, const blocks: { start: number; end: number }[] = []
message(ev: MessageEvent) {
const res = JSON.parse(ev!.data) if (fileSize >= MIN_SIZE_FOR_REORDER) {
if ('error' in res) { // File is large enough, prioritize final blocks
console.error('Upload socket error', res.error) const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
store.error = res.error.message
return // Add final blocks first
} for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
if (res.status === 'ack') { const start = finalBlocksStart + i * BLOCK_SIZE
statUpdate(res.req) const end = Math.min(start + BLOCK_SIZE, fileSize)
} else console.log('Unknown upload response', res) blocks.push({ start, end })
}, }
})
// @ts-ignore // Add remaining blocks from beginning
ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg)) for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
// @ts-ignore const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
ws.sendData = async (data: any) => { blocks.push({ start, end })
// Wait until the WS is ready to send another message }
store.uprogress.status = "uploading" } else {
await new Promise(resolve => { // File is smaller, use sequential upload
const t = setInterval(() => { for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
if (ws.bufferedAmount > 1<<20) return const end = Math.min(start + BLOCK_SIZE, fileSize)
resolve(undefined) blocks.push({ start, end })
clearInterval(t) }
}, 1)
})
store.uprogress.status = "processing"
ws.send(data)
} }
})
const worker = async () => { return blocks
const ws = await WSCreate()
while (upqueue.length) {
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 }
const data = f.file.slice(start, end)
f.cloudPos = end
// Note: files may get modified during I/O
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
ws.sendMsg(control)
// @ts-ignore
await ws.sendData(data)
if (f.cloudPos === f.file.size) upqueue.shift()
}
if (upqueue.length) startWorker()
store.uprogress.status = "idle"
workerRunning = false
} }
let workerRunning: any = false
type BlockUpload = {
file: CloudFile
blocks: { start: number; end: number }[]
nextIndex: number
completed: number
runId: number
}
let blockQueue = [] as BlockUpload[]
let workerRunning = false
let uploadRunId = 0
const enqueuePendingUploads = () => {
while (upqueue.length) {
const file = upqueue.shift()!
const blocks = getUploadBlocks(file)
blockQueue.push({ file, blocks, nextIndex: 0, completed: 0, runId: uploadRunId })
}
}
const uploadUrlForFile = (cloudName: string) => {
const normalized = cloudName.replace(/^\/+/, '')
const encoded = normalized.split('/').map(encodeURIComponent).join('/')
return `/files/${encoded}`
}
const uploadBlock = async (
upload: BlockUpload,
block: { start: number; end: number }
) => {
const body = upload.file.file.slice(block.start, block.end)
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
const fallbackReq = {
name: upload.file.cloudName,
size: upload.file.file.size,
start: block.start,
end: block.end
}
let attempt = 0
while (true) {
attempt += 1
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
try {
const res = await fetch(uploadUrlForFile(upload.file.cloudName), {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Range': range
},
body
})
if (!res.ok) {
const message = await res.text().catch(() => '')
const retryable = res.status >= 500 || res.status === 408 || res.status === 429
if (!retryable) throw new Error(message || `HTTP ${res.status}`)
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
continue
}
const payload = await res.json().catch(() => null)
return payload?.status === 'ack' && payload.req ? payload.req : fallbackReq
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'Upload cancelled') throw err
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
if (attempt % 10 === 0) {
console.warn(`Upload retry ${attempt} for ${upload.file.cloudName}: ${message}`)
}
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
}
}
}
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
inflightBlocks.set(inflightKey(name, block.start), {
name,
start: block.start,
end: block.end,
startedAt: Date.now()
})
}
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
const key = inflightKey(name, block.start)
const info = inflightBlocks.get(key)
if (!info) return
const elapsed = Math.max(1, Date.now() - info.startedAt)
smoothedBlockMs = smoothedBlockMs * 0.85 + elapsed * 0.15
inflightBlocks.delete(key)
}
const worker = async (runId: number) => {
try {
while (runId === uploadRunId) {
enqueuePendingUploads()
if (!blockQueue.length) break
const upload = blockQueue[0]!
const inflight = new Set<Promise<void>>()
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
while (
runId === uploadRunId &&
upload.nextIndex < upload.blocks.length &&
inflight.size < MAX_PARALLEL_REQUESTS
) {
const block = upload.blocks[upload.nextIndex++]!
store.uprogress.status = 'uploading'
startInflightBlock(upload.file.cloudName, block)
let task: Promise<void>
task = uploadBlock(upload, block)
.then(req => {
finishInflightBlock(upload.file.cloudName, block)
upload.completed += 1
statUpdate(req)
})
.catch(err => {
finishInflightBlock(upload.file.cloudName, block)
throw err
})
.finally(() => {
inflight.delete(task)
})
inflight.add(task)
}
if (!inflight.size) break
await Promise.race(inflight)
}
if (runId !== uploadRunId) return
if (upload.completed >= upload.blocks.length) {
blockQueue.shift()
} else {
break
}
}
} catch (err: any) {
if (runId !== uploadRunId) return
console.error('Upload error', err)
store.error = err?.message || 'Upload failed'
uploadRunId += 1
upqueue = []
blockQueue = []
inflightBlocks.clear()
} finally {
store.uprogress.status = 'idle'
workerRunning = false
if (upqueue.length) startWorker()
}
}
const startWorker = () => { const startWorker = () => {
if (workerRunning === false) workerRunning = setTimeout(() => { if (workerRunning) return
workerRunning = true workerRunning = true
worker() const runId = uploadRunId
setTimeout(() => {
void worker(runId)
}, 0) }, 0)
} }
+37 -17
View File
@@ -74,10 +74,18 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import {
createUser,
deleteUser,
getServerConfig,
listUsers,
updatePublic,
updateServerName,
updateUser
} from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { onMounted, reactive, ref, watch } from 'vue'
interface User { interface User {
username: string username: string
@@ -92,7 +100,7 @@ const success = ref('')
const copyButtonText = ref('📋') const copyButtonText = ref('📋')
const serverSettings = reactive({ const serverSettings = reactive({
public: false, public: false,
name: '', name: ''
}) })
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -163,10 +171,13 @@ const renameUser = async (user: User) => {
} }
const resetPassword = async (user: User) => { const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return if (
!confirm(`Reset password for ${user.username}? A new password will be generated.`)
)
return
try { try {
success.value = '' success.value = ''
const result = await updateUser(user.username, { password: "" }) const result = await updateUser(user.username, { password: '' })
if (result.password) { if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}` success.value = `Password reset for ${user.username}. New password: ${result.password}`
} }
@@ -188,14 +199,17 @@ const deleteUserAction = async (username: string) => {
} }
const copySuccess = async (isButtonClick: boolean = false) => { const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/) const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/)
if (passwordMatch) { if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1]!) await navigator.clipboard.writeText(passwordMatch[1]!)
if (isButtonClick) { if (isButtonClick) {
// Show "Copied!" indication on button // Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!' copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying // Hide password/key and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!') const baseMessage = success.value.replace(
/(?:Password|New password|Key): .+/,
'Copied to clipboard!'
)
success.value = baseMessage success.value = baseMessage
// Hide the entire message after 3 seconds // Hide the entire message after 3 seconds
setTimeout(() => { setTimeout(() => {
@@ -258,18 +272,24 @@ onMounted(() => {
}) })
// Load users and config when dialog opens // Load users and config when dialog opens
watch(() => store.dialog, (newVal) => { watch(
if (newVal === 'usermgmt') { () => store.dialog,
loadServerConfig() newVal => {
if (!store.server.paskia) { if (newVal === 'usermgmt') {
loadUsers() loadServerConfig()
if (!store.server.paskia) {
loadUsers()
}
} }
} }
}) )
watch(() => store.server.public, (newVal) => { watch(
serverSettings.public = newVal || false () => store.server.public,
}) newVal => {
serverSettings.public = newVal || false
}
)
</script> </script>
<style scoped> <style scoped>
+267
View File
@@ -0,0 +1,267 @@
<template>
<ModalDialog name=tokens title="My API Tokens">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
<!-- Creation form -->
<div v-if="mode === 'creating'" class="create-form">
<label for="token-name">Token name (optional)</label>
<input
id="token-name"
v-model="newTokenName"
type="text"
placeholder="e.g. backup-script"
@keyup.enter="submitCreate"
ref="nameInput"
/>
<div class="form-actions">
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
<button @click="cancelCreate" class="button">Cancel</button>
</div>
</div>
<!-- Creation result -->
<div v-else-if="mode === 'created' && createdToken" class="created-result">
<p class="success-title"> Token created</p>
<p class="hint">Copy this URL it will not be shown again.</p>
<div class="url-box">
<code class="token-url">{{ createdToken.url }}</code>
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
</div>
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
<div class="form-actions">
<button @click="finishCreate" class="button primary">Done</button>
</div>
</div>
<!-- Token list -->
<div v-else>
<button @click="startCreate" class="button" title="Add new token"> Add Token</button>
<table v-if="tokens.length">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in tokens" :key="token.id">
<td>{{ token.name || 'Unnamed' }}</td>
<td>{{ formatDate(token.created) }}</td>
<td>
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty">You have no API tokens.</p>
</div>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import type { ISimpleError } from '@/repositories/Client'
import { createToken, deleteToken, listTokens } from '@/repositories/User'
import { useMainStore } from '@/stores/main'
import { nextTick, ref, watch } from 'vue'
interface Token {
id: string
username: string
sso_user_id: string
name: string
created: number
}
interface CreatedToken extends Token {
key: string
url: string
}
const store = useMainStore()
const loading = ref(true)
const tokens = ref<Token[]>([])
const mode = ref<'list' | 'creating' | 'created'>('list')
const newTokenName = ref('')
const creating = ref(false)
const createdToken = ref<CreatedToken | null>(null)
const copyButtonText = ref('📋')
const nameInput = ref<HTMLInputElement | null>(null)
const close = () => {
store.dialog = ''
resetCreate()
}
const resetCreate = () => {
mode.value = 'list'
newTokenName.value = ''
creating.value = false
createdToken.value = null
copyButtonText.value = '📋'
}
const loadTokens = async () => {
try {
loading.value = true
const data = await listTokens()
tokens.value = data.tokens
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to load tokens')
} finally {
loading.value = false
}
}
const startCreate = () => {
mode.value = 'creating'
nextTick(() => nameInput.value?.focus())
}
const cancelCreate = () => {
resetCreate()
}
const ensureFilesBaseUrl = (url: string) => {
const trimmed = url.replace(/\/+$/, '')
if (trimmed.endsWith('/files')) return trimmed
return `${trimmed}/files`
}
const submitCreate = async () => {
if (creating.value) return
creating.value = true
try {
const result = await createToken(newTokenName.value)
await loadTokens()
if (result.url) {
createdToken.value = {
...(result as CreatedToken),
url: ensureFilesBaseUrl((result as CreatedToken).url)
}
mode.value = 'created'
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create token')
mode.value = 'list'
} finally {
creating.value = false
}
}
const finishCreate = () => {
resetCreate()
}
const copyUrl = async () => {
if (!createdToken.value) return
await navigator.clipboard.writeText(createdToken.value.url)
copyButtonText.value = '✅ Copied!'
setTimeout(() => {
copyButtonText.value = '📋'
}, 2000)
}
const deleteTokenAction = async (tokenId: string) => {
if (!confirm('Revoke this token? It will no longer work.')) return
try {
await deleteToken(tokenId)
await loadTokens()
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to revoke token')
}
}
const formatDate = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
// Load tokens when dialog opens
watch(
() => store.dialog,
newVal => {
if (newVal === 'tokens') {
resetCreate()
loadTokens()
}
}
)
</script>
<style scoped>
.hint {
color: #666;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.empty {
color: #888;
font-style: italic;
margin: 1rem 0;
}
.create-form {
margin-bottom: 1rem;
}
.create-form label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #444;
}
.create-form input {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
margin-bottom: 0.5rem;
}
.create-form input:focus {
outline: none;
border-color: #f80;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.created-result {
margin-bottom: 1rem;
}
.success-title {
color: #080;
font-weight: bold;
margin: 0 0 0.5rem 0;
}
.url-box {
display: flex;
gap: 0.5rem;
align-items: center;
background: #f0f0f0;
padding: 0.75rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
.token-url {
flex: 1;
word-break: break-all;
font-size: 0.875rem;
color: #222;
}
.dialog-buttons {
margin-top: 1rem;
text-align: right;
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
import './assets/main.css' import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+1 -1
View File
@@ -1,4 +1,4 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia' import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
// Type for API error responses // Type for API error responses
interface ApiError { interface ApiError {
+79 -30
View File
@@ -1,4 +1,5 @@
import { formatSize, formatUnixDate } from "@/utils" import { useMainStore } from '@/stores/main'
import { FILE_TYPES, formatSize, formatUnixDate } from '@/utils'
export type FUID = string export type FUID = string
@@ -11,68 +12,115 @@ export type DocProps = {
mtime: number mtime: number
dir: boolean dir: boolean
ghost?: boolean ghost?: boolean
expires?: number // Unix timestamp for ghost expiry expires?: number // Unix timestamp for ghost expiry
ar?: number // Aspect ratio (height/width) from server, if known
} }
export class Doc { export class Doc {
public loc: string = "" public loc: string = ''
public key: FUID = "" public key: FUID = ''
public size: number = 0 public size: number = 0
public allocated: number = 0 public allocated: number = 0
public mtime: number = 0 public mtime: number = 0
public dir: boolean = false public dir: boolean = false
public ghost: boolean = false public ghost: boolean = false
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry) public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */ /** @internal Use the name getter/setter instead */
public _name: string = "" public _name: string = ''
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
Object.assign(this, rest) Object.assign(this, rest)
if (name) this._name = name // Skip validation/haystack for bulk loading if (name) this._name = name // Skip validation/haystack for bulk loading
}
get name() {
return this._name
} }
get name() { return this._name }
set name(name: string) { set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name this._name = name
} }
get sizedisp(): string { return formatSize(this.size) } get sizedisp(): string {
return formatSize(this.size)
}
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */ /** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
get sparseIndicator(): string { get sparseIndicator(): string {
if (this.dir || this.size <= this.allocated) return '' if (this.dir || this.size <= this.allocated) return ''
if (this.allocated === 0) return '⭕' // exactly zero if (this.allocated === 0) return '⭕' // exactly zero
const ratio = this.allocated / this.size const ratio = this.allocated / this.size
// Round to nearest 25%: ◔◑◕⬤ // Round to nearest 25%: ◔◑◕⬤
const rounded = Math.round(ratio * 4) // 0,1,2,3,4 const rounded = Math.round(ratio * 4) // 0,1,2,3,4
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
}
get modified(): string {
return formatUnixDate(this.mtime)
} }
get modified(): string { return formatUnixDate(this.mtime) }
get url(): string { get url(): string {
const p = this.loc ? `${this.loc}/${this.name}` : this.name const p = this.loc ? `${this.loc}/${this.name}` : this.name
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23') return this.dir
? '/#/' + `${p}/`.replaceAll('#', '%23')
: `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
} }
get urlrouter(): string { get urlrouter(): string {
return this.url.replace(/^\/#/, '') return this.url.replace(/^\/#/, '')
} }
get img(): boolean { get img(): boolean {
// Folders cannot be images return (
if (this.dir) return false !this.dir && (FILE_TYPES.imageBrowser as readonly string[]).includes(this.ext)
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext) )
}
get video(): boolean {
return (FILE_TYPES.video as readonly string[]).includes(this.ext)
}
get audio(): boolean {
return (FILE_TYPES.audio as readonly string[]).includes(this.ext)
}
get archive(): boolean {
return (FILE_TYPES.archive as readonly string[]).includes(this.ext)
}
get document(): boolean {
return (FILE_TYPES.document as readonly string[]).includes(this.ext)
}
// Images that require server-side preview (browsers cannot display them natively)
get image(): boolean {
return (FILE_TYPES.image as readonly string[]).includes(this.ext)
}
get print(): boolean {
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
}
get text(): boolean {
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
}
get editurl(): string {
if (!this.text) return ''
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated)
} }
get previewable(): boolean { get previewable(): boolean {
// Folders cannot be previewable
if (this.dir) return false if (this.dir) return false
if (this.img) return true return (
// Not a comprehensive list, but good enough for now this.img ||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext) this.video ||
this.audio ||
this.image ||
this.print ||
(this.document && useMainStore().server.office_previews !== false)
)
} }
get previewurl(): string { get previewurl(): string {
return this.url.replace(/^\/files/, '/preview') return !this.complete || !this.previewable
? ''
: this.url.replace(/^\/files/, '/preview')
} }
get ext(): string { get ext(): string {
const dotIndex = this.name.lastIndexOf('.') const dotIndex = this.name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === this.name.length - 1) return '' return dotIndex === -1 || dotIndex === this.name.length - 1
return this.name.slice(dotIndex + 1).toLowerCase() ? ''
: this.name.slice(dotIndex + 1).toLowerCase()
} }
} }
export type errorEvent = { export type errorEvent = {
@@ -86,13 +134,14 @@ export type errorEvent = {
// Raw types the backend /api/watch sends us // Raw types the backend /api/watch sends us
export type FileEntry = [ export type FileEntry = [
number, // level number, // level
string, // name string, // name
FUID, FUID,
number, // mtime number, // mtime
number, // size number, // size
number, // allocated (actual disk usage) number, // allocated (actual disk usage)
number, // isfile number, // isfile
number? // ar: aspect ratio (height/width), present if known
] ]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+43 -4
View File
@@ -16,7 +16,11 @@ export async function logoutUser() {
return data return data
} }
export async function changePassword(username: string, passwordChange: string, password: string) { export async function changePassword(
username: string,
passwordChange: string,
password: string
) {
const data = await Client.post(url_password, { const data = await Client.post(url_password, {
username, username,
passwordChange, passwordChange,
@@ -32,7 +36,11 @@ export async function listUsers() {
return data return data
} }
export async function createUser(username: string, password?: string, privileged?: boolean) { export async function createUser(
username: string,
password?: string,
privileged?: boolean
) {
const data = await Client.post(url_users, { const data = await Client.post(url_users, {
username, username,
password, password,
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
return data return data
} }
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) { export async function updateUser(
username: string,
changes: { password?: string; privileged?: boolean }
) {
const data = await Client.put(`${url_users}/${username}`, changes) const data = await Client.put(`${url_users}/${username}`, changes)
return data return data
} }
@@ -63,5 +74,33 @@ export async function updateServerName(name: string) {
export async function getServerConfig() { export async function getServerConfig() {
const data = await Client.get('/api/config') const data = await Client.get('/api/config')
return data as { name: string, public: boolean } return data as { name: string; public: boolean }
}
export const url_tokens = '/api/tokens'
export async function listTokens() {
const data = await Client.get(url_tokens)
return data
}
export async function createToken(name: string) {
const data = await Client.post(url_tokens, { name })
return data
}
export async function deleteToken(tokenId: string) {
const data = await Client.delete(`${url_tokens}/${tokenId}`)
return data
}
export async function createShareToken(paths: string[], mode: 'ro' | 'rw' = 'ro') {
const data = await Client.post('/api/share-tokens', { paths, mode })
return data as {
id: string
key: string
url: string
mode: 'ro' | 'rw'
paths: string[]
}
} }
+26 -19
View File
@@ -1,9 +1,7 @@
import { useMainStore } from "@/stores/main" import { useMainStore } from '@/stores/main'
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia' import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document" import type { FileEntry, UpdateEntry, errorEvent } from './Document'
export const controlUrl = '/api/control'
export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch' export const watchUrl = '/api/watch'
let tree = [] as FileEntry[] let tree = [] as FileEntry[]
@@ -27,18 +25,22 @@ export const loadSession = () => {
console.log(`Loaded session with ${tree.length} items cached`) console.log(`Loaded session with ${tree.length} items cached`)
return true return true
} catch (error) { } catch (error) {
console.log("Loading session failed", error) console.log('Loading session failed', error)
return false return false
} }
} }
const saveSession = () => { const saveSession = () => {
localStorage["cista-files"] = JSON.stringify(tree) localStorage['cista-files'] = JSON.stringify(tree)
} }
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { export const connect = (
path: string,
handlers: Partial<Record<keyof WebSocketEventMap, any>>
) => {
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws'))) const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler) for (const [event, handler] of Object.entries(handlers))
webSocket.addEventListener(event, handler)
return webSocket return webSocket
} }
@@ -53,7 +55,7 @@ async function handleWsAuthError(msg: any) {
// Stop reconnection attempts while showing auth dialog // Stop reconnection attempts while showing auth dialog
awaitingAuth = true awaitingAuth = true
store.authInProgress = true store.authInProgress = true
store.error = '' // Clear any connection message store.error = '' // Clear any connection message
if (watchTimeout !== null) { if (watchTimeout !== null) {
clearTimeout(watchTimeout) clearTimeout(watchTimeout)
watchTimeout = null watchTimeout = null
@@ -91,9 +93,9 @@ export const watchConnect = () => {
wsWatch = connect(watchUrl, { wsWatch = connect(watchUrl, {
message: handleWatchMessage, message: handleWatchMessage,
close: watchReconnect, close: watchReconnect
}) })
wsWatch.addEventListener("message", event => { wsWatch.addEventListener('message', event => {
if (store.connected) return if (store.connected) return
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if ('error' in msg) { if ('error' in msg) {
@@ -105,7 +107,7 @@ export const watchConnect = () => {
} }
return return
} }
if ("server" in msg) { if ('server' in msg) {
console.log('Connected to backend', msg) console.log('Connected to backend', msg)
store.server = msg.server store.server = msg.server
store.connected = true store.connected = true
@@ -143,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
return return
} }
if (store.connected) { if (store.connected) {
console.warn("Disconnected from server", event) console.warn('Disconnected from server', event)
store.connected = false store.connected = false
store.error = 'Reconnecting...' store.error = 'Reconnecting...'
} }
@@ -153,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
watchTimeout = setTimeout(watchConnect, reconnDelay) watchTimeout = setTimeout(watchConnect, reconnDelay)
} }
const handleWatchMessage = (event: MessageEvent) => { const handleWatchMessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
switch (true) { switch (true) {
@@ -163,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
case !!msg.update: case !!msg.update:
handleUpdateMessage(msg) handleUpdateMessage(msg)
break break
case !!msg.ar: {
const store = useMainStore()
store.updateAr(msg.ar as Record<string, number>)
break
}
case !!msg.space: case !!msg.space:
const store = useMainStore() const store = useMainStore()
store.space = msg.space store.space = msg.space
@@ -194,13 +200,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
if (action === 'k') { if (action === 'k') {
newtree.push(...tree.slice(oidx, oidx + arg)) newtree.push(...tree.slice(oidx, oidx + arg))
oidx += arg oidx += arg
} } else if (action === 'd') oidx += arg
else if (action === 'd') oidx += arg
else if (action === 'i') newtree.push(...arg) else if (action === 'i') newtree.push(...arg)
else console.log("Unknown update action", action, arg) else console.log('Unknown update action', action, arg)
} }
if (oidx != tree.length) if (oidx != tree.length)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`) throw Error(
`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`
)
store.updateRoot(newtree) store.updateRoot(newtree)
tree = newtree tree = newtree
saveSession() saveSession()
+20 -1
View File
@@ -1,5 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { useMainStore } from '@/stores/main'
import ExplorerView from '@/views/ExplorerView.vue' import ExplorerView from '@/views/ExplorerView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
function getPathDepth(path: string): number {
const pathPart = decodeURIComponent(path).split('//')[0] ?? ''
return pathPart.split('/').filter(Boolean).length
}
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
@@ -12,4 +18,17 @@ const router = createRouter({
] ]
}) })
router.beforeEach((to, from) => {
const store = useMainStore()
const toDepth = getPathDepth(to.path)
const fromDepth = getPathDepth(from.path)
if (toDepth > fromDepth) {
store.transitionDirection = 'forward'
} else if (toDepth < fromDepth) {
store.transitionDirection = 'backward'
} else {
store.transitionDirection = 'none'
}
})
export default router export default router
+87 -51
View File
@@ -1,11 +1,11 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' import type { FUID, FileEntry, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia' import { resumeWatching, watchConnect } from '@/repositories/WS'
import { collator } from '@/utils' import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS' import { type SortOrder, sorted } from '@/utils/docsort'
import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker' import SearchWorker from '@/workers/searchWorker?worker'
import { getDocuments, setDocuments, documentRef } from './documentStore' import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
// Singleton search worker instance // Singleton search worker instance
let searchWorker: Worker | null = null let searchWorker: Worker | null = null
@@ -19,8 +19,8 @@ function getSearchWorker(): Worker {
if (!searchWorker) { if (!searchWorker) {
searchWorker = new SearchWorker() searchWorker = new SearchWorker()
// Set up message handler once // Set up message handler once
searchWorker.onmessage = (e) => { searchWorker.onmessage = e => {
if (!searchStore || e.data.id !== searchId) return // Stale result if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances // Convert plain data back to Doc instances
const docs = e.data.docs.map((d: any) => new Doc(d)) const docs = e.data.docs.map((d: any) => new Doc(d))
@@ -34,7 +34,7 @@ function getSearchWorker(): Worker {
// Throttle rapid intermediate updates to reduce UI flicker // Throttle rapid intermediate updates to reduce UI flicker
const now = performance.now() const now = performance.now()
if (!e.data.done && now - lastResultUpdate < 50) { if (!e.data.done && now - lastResultUpdate < 50) {
return // Skip intermediate update if too recent return // Skip intermediate update if too recent
} }
lastResultUpdate = now lastResultUpdate = now
@@ -73,33 +73,39 @@ export const useMainStore = defineStore('main', {
searchLoading: false, searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null, _searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, // Permanent status message (e.g., "Reconnecting...") error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses) toast: '' as string, // Temporary toast (auto-dismisses)
toastTimeout: null as ReturnType<typeof setTimeout> | null, toastTimeout: null as ReturnType<typeof setTimeout> | null,
connected: false, connected: false,
authInProgress: false, authInProgress: false,
cursor: '' as string, cursor: '' as string,
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean }, server: {} as Record<string, any> & {
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied', public?: boolean
paskia?: boolean
office_previews?: boolean
},
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
prefs: { prefs: {
gallery: false, gallery: false,
sortListing: '' as SortOrder, sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder, sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key) searchHotkey: '/' // Character shown for search hotkey (Slash key)
}, },
user: { user: {
username: '' as string, username: '' as string,
privileged: false as boolean, privileged: false as boolean,
isLoggedIn: false as boolean, isLoggedIn: false as boolean
}, },
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
editorSave: null as null | (() => void),
space: { space: {
disk: 0, disk: 0,
free: 0, free: 0,
used: 0, used: 0,
storage: 0, storage: 0,
allocated: 0, allocated: 0
} }
}), }),
persist: { persist: {
@@ -114,30 +120,35 @@ export const useMainStore = defineStore('main', {
tree.selected = Array.from(tree.selected) tree.selected = Array.from(tree.selected)
return JSON.stringify(tree) return JSON.stringify(tree)
} }
}, }
}, },
actions: { actions: {
updateRoot(root: FileEntry[]) { updateRoot(root: FileEntry[]) {
const docs = [] const docs = []
let loc = [] as string[] let loc = [] as string[]
for (const [level, name, key, mtime, size, allocated, isfile] of root) { for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
loc = loc.slice(0, level - 1) loc = loc.slice(0, level - 1)
docs.push(new Doc({ docs.push(
name, new Doc({
loc: level ? loc.join('/') : '/', name,
key, loc: level ? loc.join('/') : '/',
size, key,
allocated, size,
mtime, allocated,
dir: !isfile, mtime,
})) dir: !isfile,
ar
})
)
loc.push(name) loc.push(name)
} }
// Store in non-reactive external storage // Store in non-reactive external storage
setDocuments(docs) setDocuments(docs)
// Clear ghosts that now exist in the real list // Clear ghosts that now exist in the real list
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name)) 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)) 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) // Clear hidden paths that no longer exist (deletion confirmed)
for (const path of this.hiddenPaths.keys()) { for (const path of this.hiddenPaths.keys()) {
if (!realPaths.has(path)) this.hiddenPaths.delete(path) if (!realPaths.has(path)) this.hiddenPaths.delete(path)
@@ -149,6 +160,22 @@ export const useMainStore = defineStore('main', {
// Sync documents to search worker // Sync documents to search worker
this.syncSearchWorker() this.syncSearchWorker()
}, },
/** Patch aspect ratios on existing docs from a server ar update message */
updateAr(arMap: Record<string, number>) {
const docs = getDocuments()
let changed = false
for (const doc of docs) {
const ar = arMap[doc.key]
if (ar != null && doc.ar !== ar) {
doc.ar = ar
changed = true
}
}
if (changed) {
triggerUpdate()
this.docVersion++
}
},
/** Add a ghost file/folder for optimistic UI updates */ /** Add a ghost file/folder for optimistic UI updates */
addGhost(doc: Doc) { addGhost(doc: Doc) {
doc.ghost = true doc.ghost = true
@@ -224,14 +251,20 @@ export const useMainStore = defineStore('main', {
size: doc.size, size: doc.size,
allocated: doc.allocated, allocated: doc.allocated,
mtime: doc.mtime, mtime: doc.mtime,
dir: doc.dir, dir: doc.dir
})) }))
worker.postMessage({ type: 'update', documents: docData }) worker.postMessage({ type: 'update', documents: docData })
}, },
/** Notify UI/search that existing document objects were mutated in-place */
documentsChanged() {
triggerUpdate()
this.docVersion++
this.syncSearchWorker()
},
search(query: string, loc: string) { search(query: string, loc: string) {
const worker = getSearchWorker() const worker = getSearchWorker()
const id = ++searchId const id = ++searchId
searchStore = this // Store reference for worker callback searchStore = this // Store reference for worker callback
// Update query immediately so watchers know we're handling this // Update query immediately so watchers know we're handling this
this.query = query this.query = query
@@ -264,7 +297,8 @@ export const useMainStore = defineStore('main', {
// Delay showing loading indicator to avoid flicker on fast searches // Delay showing loading indicator to avoid flicker on fast searches
loadingTimer = setTimeout(() => { loadingTimer = setTimeout(() => {
if (searchId === id) { // Still the current search if (searchId === id) {
// Still the current search
this.searchLoading = true this.searchLoading = true
} }
loadingTimer = null loadingTimer = null
@@ -294,9 +328,10 @@ export const useMainStore = defineStore('main', {
this.connected = false this.connected = false
this.dialog = '' this.dialog = ''
this.cursor = '' this.cursor = ''
this.editorSave = null
}, },
async logout() { async logout() {
console.log("Logout") console.log('Logout')
try { try {
const res = await fetch('/auth/api/logout', { method: 'POST' }) const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) { if (!res.ok) {
@@ -326,25 +361,29 @@ export const useMainStore = defineStore('main', {
showSortToast(order: SortOrder | '') { showSortToast(order: SortOrder | '') {
const labels: Record<string, string> = { const labels: Record<string, string> = {
'': 'Folders first', '': 'Folders first',
'name': 'Alphabetical order', name: 'Alphabetical order',
'modified': 'Newest first', modified: 'Newest first',
'size': 'Largest first', size: 'Largest first'
} }
this.showToast(labels[order] || order, 1200) this.showToast(labels[order] || order, 1200)
}, },
focusBreadcrumb() { focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus() ;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
}, },
cancelDownloads() { cancelDownloads() {
location.reload() // FIXME location.reload() // FIXME
}, },
cancelUploads() { cancelUploads() {
location.reload() // FIXME location.reload() // FIXME
}, }
}, },
getters: { getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, sortOrder(): SortOrder {
isUserLogged(): boolean { return this.user.isLoggedIn }, return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
},
isUserLogged(): boolean {
return this.user.isLoggedIn
},
/** Get documents count (triggers on docVersion change) */ /** Get documents count (triggers on docVersion change) */
documentCount(): number { documentCount(): number {
// Access docVersion to make this reactive // Access docVersion to make this reactive
@@ -366,7 +405,7 @@ export const useMainStore = defineStore('main', {
missing: new Set(), missing: new Set(),
docs: {}, docs: {},
keys: [], keys: [],
recursive: [], recursive: []
} }
for (const doc of docs) { for (const doc of docs) {
if (selected.has(doc.key)) { if (selected.has(doc.key)) {
@@ -378,22 +417,19 @@ export const useMainStore = defineStore('main', {
// What did we not select? // What did we not select?
for (const key of selected) if (!found.has(key)) ret.missing.add(key) for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively // Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Doc) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel)
ret.recursive.push([rel, full, doc])
}
for (const key of ret.keys) { for (const key of ret.keys) {
const base = ret.docs[key]! const base = ret.docs[key]!
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length const nremove = base.loc.length
add(base.name, basepath, base) ret.recursive.push([base.name, basepath, base])
for (const doc of docs) { for (const doc of docs) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { if (
doc.loc === basepath ||
(doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/')
) {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove) const rel = full.slice(nremove)
add(rel, full, doc) ret.recursive.push([rel, full, doc])
} }
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { clearTree } from '@/repositories/WS'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed } from 'vue' import { computed } from 'vue'
import { useMainStore } from './main' import { useMainStore } from './main'
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => { export const useSsoAuthStore = defineStore('ssoAuth', () => {
const isExternalAuth = computed(() => { const isExternalAuth = computed(() => {
+12 -3
View File
@@ -1,13 +1,22 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore' import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
export const exists = (path: string[]) => { export const exists = (path: string[]) => {
const store = useMainStore() const store = useMainStore()
// Access docVersion to make this reactive // Access docVersion to make this reactive
void store.docVersion void store.docVersion
if (path.length === 0) return true
const p = path.join('/') const p = path.join('/')
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p) const hidden = store.hiddenPaths
const inDocs = getDocuments().some(doc => {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return full === p && !hidden.has(full)
})
if (inDocs) return true
return store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return full === p && !hidden.has(full)
})
} }
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */ /** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+124 -31
View File
@@ -26,26 +26,39 @@ export function formatUnixDate(t: number) {
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (adiff <= 5000) return 'now' if (adiff <= 5000) return 'now'
if (adiff <= 60000) { if (adiff <= 60000) {
return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 1000), 'second')
.replace(' ago', '')
.replaceAll(' ', '\u202F')
} }
if (adiff <= 3600000) { if (adiff <= 3600000) {
return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 60000), 'minute')
.replace('utes', '')
.replace('ute', '')
.replaceAll(' ', '\u202F')
} }
if (adiff <= 86400000) { if (adiff <= 86400000) {
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 3600000), 'hour')
.replaceAll(' ', '\u202F')
} }
if (adiff <= 604800000) { if (adiff <= 604800000) {
return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F') return formatter
.format(Math.round(diff / 86400000), 'day')
.replaceAll(' ', '\u202F')
} }
let d = date.toLocaleDateString('en-ie', { let d = date
weekday: 'short', .toLocaleDateString('en-ie', {
year: 'numeric', weekday: 'short',
month: 'short', year: 'numeric',
day: 'numeric' month: 'short',
}).replace("Sept", "Sep") day: 'numeric'
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space) })
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday .replace('Sept', 'Sep')
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
return d return d
} }
@@ -56,41 +69,121 @@ export function getFileExtension(filename: string) {
} }
return filename.slice(dotIndex + 1) return filename.slice(dotIndex + 1)
} }
interface FileTypes { export const FILE_TYPES = {
[key: string]: string[]
}
const filetypes: FileTypes = {
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'], video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'], audio: ['mp3', 'flac', 'ogg', 'aac'],
pdf: ['pdf'], archive: ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'],
} document: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf'],
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
// Images that require server-side preview (browsers cannot display them natively)
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
print: ['epub', 'mobi', 'pdf'],
text: [
'txt',
'md',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'conf',
'config',
'cfg',
'log',
'csv',
'tsv',
'py',
'js',
'ts',
'jsx',
'tsx',
'html',
'htm',
'css',
'scss',
'sass',
'less',
'vue',
'php',
'rb',
'go',
'rs',
'java',
'c',
'cpp',
'h',
'hpp',
'cs',
'swift',
'kt',
'sh',
'bash',
'zsh',
'fish',
'ps1',
'bat',
'cmd',
'sql',
'lua',
'r',
'pl',
'dockerfile',
'makefile',
'gitignore',
'gitattributes',
'env',
'diff',
'patch'
]
} as const
export function getFileType(name: string): string { export type FileCategory = keyof typeof FILE_TYPES
export function getFileType(name: string): FileCategory | 'unknown' {
const dotIndex = name.lastIndexOf('.') const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown' if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
const ext = name.slice(dotIndex + 1).toLowerCase() const ext = name.slice(dotIndex + 1).toLowerCase()
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown' for (const category of Object.keys(FILE_TYPES) as FileCategory[]) {
if ((FILE_TYPES[category] as readonly string[]).includes(ext)) {
return category
}
}
return 'unknown'
} }
// Prebuilt for fast & consistent sorting // Prebuilt for fast & consistent sorting
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) export const collator = new Intl.Collator('en', {
sensitivity: 'base',
numeric: true,
usage: 'search'
})
// Preformat document names for faster search // Preformat document names for faster search
export function haystackFormat(str: string) { export function haystackFormat(str: string) {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return '^' + based + '$' return '^' + based + '$'
} }
// Preformat search string for faster search // Preformat search string for faster search
export function needleFormat(query: string) { export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = query
return {based, words: based.split(/\s+/)} .normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return { based, words: based.split(/\s+/) }
} }
// Test if haystack includes needle // Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) { export function localeIncludes(
const {based, words} = filter haystack: string,
return haystack.includes(based) || words && words.every(word => haystack.includes(word)) filter: { based: string; words: string[] }
) {
const { based, words } = filter
return (
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
)
} }
+124
View File
@@ -0,0 +1,124 @@
type ScrollOptions = {
topPad?: number
bottomPad?: number
keyboardWindowMs?: number
getScrollContainer?: () => HTMLElement | null
}
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
const {
topPad = 84,
bottomPad = 84,
keyboardWindowMs = 260,
getScrollContainer = () =>
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
} = options
let scrollAnimationFrame: number | null = null
let scrollTargetY: number | null = null
let scrollVelocity = 0
let keyboardFollowUntil = 0
const markKeyboardFollow = () => {
keyboardFollowUntil = performance.now() + keyboardWindowMs
}
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
const clampScrollY = (y: number, scroller: HTMLElement) => {
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
return Math.min(maxY, Math.max(0, y))
}
const cursorScrollTarget = (el: HTMLElement): number | null => {
const scroller = getScrollContainer() ?? document.documentElement
const rect = el.getBoundingClientRect()
const scrollerRect = scroller.getBoundingClientRect()
const visibleTop = scrollerRect.top + topPad
const visibleBottom = scrollerRect.bottom - bottomPad
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
if (rect.top < visibleTop) {
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
}
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
}
const runSmoothCursorScroll = () => {
if (scrollAnimationFrame != null) return
const step = () => {
const scroller = getScrollContainer() ?? document.documentElement
if (scrollTargetY == null) {
scrollVelocity *= 0.68
if (Math.abs(scrollVelocity) > 0.05) {
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
scroller.scrollTop = next
scrollAnimationFrame = requestAnimationFrame(step)
return
}
scrollVelocity = 0
scrollAnimationFrame = null
return
}
const current = scroller.scrollTop
const delta = scrollTargetY - current
const absDelta = Math.abs(delta)
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
scroller.scrollTop = scrollTargetY
scrollVelocity = 0
scrollTargetY = null
scrollAnimationFrame = null
return
}
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
const damping = 0.76
scrollVelocity += delta * stiffness
scrollVelocity *= damping
const next = clampScrollY(current + scrollVelocity, scroller)
if (next === current) scrollVelocity = 0
scroller.scrollTop = next
scrollAnimationFrame = requestAnimationFrame(step)
}
scrollAnimationFrame = requestAnimationFrame(step)
}
const keepVisible = (el: HTMLElement | null) => {
if (!keyboardFollowActive()) {
scrollTargetY = null
scrollVelocity = 0
return
}
if (!el) {
scrollTargetY = null
return
}
const target = cursorScrollTarget(el)
if (target == null) {
scrollTargetY = null
return
}
scrollTargetY = target
runSmoothCursorScroll()
}
const cancel = () => {
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
scrollAnimationFrame = null
scrollTargetY = null
scrollVelocity = 0
keyboardFollowUntil = 0
}
return { markKeyboardFollow, keepVisible, cancel }
}
+79 -37
View File
@@ -1,29 +1,32 @@
<template> <template>
<Gallery <div class="transition-wrapper">
v-if="store.prefs.gallery" <Transition
ref="fileExplorer" :name="transitionName"
:key="`gallery-${folderPath}`" @after-enter="onAfterEnter"
:path="props.path" >
:documents="documents" <KeepAlive>
/> <component
<FileExplorer :is="store.prefs.gallery ? Gallery : FileExplorer"
v-else :key="cacheKey"
ref="fileExplorer" ref="fileExplorer"
:key="`explorer-${folderPath}`" class="explorer-content"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
</KeepAlive>
</Transition>
</div>
<div v-if="store.searchLoading" class="search-loading">Searching...</div> <div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue' import FileExplorer from '@/components/FileExplorer.vue'
import { useMainStore } from '@/stores/main' import Gallery from '@/components/Gallery.vue'
import { getDocuments } from '@/stores/documentStore' import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import { computed, nextTick, ref, watch, watchEffect } from 'vue'
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -34,13 +37,38 @@ const props = defineProps<{
// Folder path for component keys - only recreate component when folder changes, not search // Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/')) const folderPath = computed(() => props.path.join('/'))
const cacheKey = computed(
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
)
const transitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
})
const folderScrollTop = new Map<string, number>()
const scrollKey = (path: string) => path || '/'
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
const restoreScroll = (path: string) => {
const scroller = getMainScroller()
if (!scroller) return
const top = folderScrollTop.get(scrollKey(path)) ?? 0
scroller.scrollTop = top
}
const onAfterEnter = () => {
store.transitionDirection = 'none'
restoreScroll(folderPath.value)
}
// Handle route-based search changes (back/forward navigation, direct URL) // Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing) // Skip if store.query already matches (means we triggered this via typing)
watch( watch(
() => [props.query, props.path.join('/')] as const, () => [props.query, props.path.join('/')] as const,
([query, loc]) => { ([query, loc]) => {
if (store.query === query) return // Already searching this query if (store.query === query) return // Already searching this query
store.search(query, loc) store.search(query, loc)
}, },
{ immediate: true } { immediate: true }
@@ -55,9 +83,14 @@ const documents = computed(() => {
// Access docVersion to make this reactive to document changes // Access docVersion to make this reactive to document changes
void store.docVersion void store.docVersion
const hidden = store.hiddenPaths const hidden = store.hiddenPaths
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)) 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) // 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)) 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 // Merge: ghosts that don't conflict with real docs
const realNames = new Set(docs.map(d => d.name)) const realNames = new Set(docs.map(d => d.name))
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))] const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
@@ -66,7 +99,9 @@ const documents = computed(() => {
// Search results from worker (also filter hidden) // Search results from worker (also filter hidden)
const hidden = store.hiddenPaths const hidden = store.hiddenPaths
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)) const docs = store.searchResults.filter(
doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
)
// Custom sort override in effect? Use grouped sorting to keep folders together // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
@@ -80,25 +115,32 @@ watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
}) })
watch(
folderPath,
async (path, oldPath) => {
const scroller = getMainScroller()
if (scroller && oldPath !== undefined) {
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
}
await nextTick()
requestAnimationFrame(() => restoreScroll(path))
},
{ immediate: true }
)
// Only auto-switch gallery mode when entering a new folder or on initial file list load // Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => { watch(
// React to path change or initial document load (0 → non-zero) [() => props.path.join('/'), () => store.documentCount],
if (path === oldPath && oldLen !== undefined && oldLen > 0) return ([path, len], [oldPath, oldLen]) => {
store.prefs.gallery = documents.value.some(d => d.previewable) // React to path change or initial document load (0 → non-zero)
}, { immediate: true }) if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
},
{ immediate: true }
)
</script> </script>
<style scoped> <style scoped>
.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 { .search-loading {
position: fixed; position: fixed;
bottom: 1rem; bottom: 1rem;
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="text-editor">
<div class="editor-body">
<div v-if="loading" class="status">Loading</div>
<div v-else-if="error" class="status error">{{ error }}</div>
<div v-else ref="editorHost" class="editor-host"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { indentWithTab } from '@codemirror/commands'
import { LanguageDescription } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { Compartment, EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, keymap } from '@codemirror/view'
import { basicSetup } from 'codemirror'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref
} from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const store = useMainStore()
const MAX_SIZE = 1024 * 1024 // 1 MiB
const filePath = computed(() => {
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
return raw.replace(/^\//, '').replace(/\/$/, '')
})
const filename = computed(() => filePath.value.split('/').pop() || '')
const filesUrl = computed(() => {
return (
'/files/' +
filePath.value
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
)
})
const content = ref('')
const original = ref('')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const editorHost = ref<HTMLDivElement | null>(null)
let editorView: EditorView | null = null
const languageCompartment = new Compartment()
const dirty = computed(() => content.value !== original.value)
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
let beforeUnloadActive = false
const activateEditorBindings = () => {
store.editorSave = save
if (!beforeUnloadActive) {
window.addEventListener('beforeunload', beforeUnload)
beforeUnloadActive = true
}
}
const deactivateEditorBindings = () => {
if (store.editorSave === save) {
store.editorSave = null
}
if (beforeUnloadActive) {
window.removeEventListener('beforeunload', beforeUnload)
beforeUnloadActive = false
}
}
const detectLanguage = async () => {
const language = LanguageDescription.matchFilename(languages, filename.value)
if (!language) return []
try {
return [await language.load()]
} catch {
return []
}
}
const initEditor = async (text: string) => {
if (!editorHost.value) return
const languageExtensions = await detectLanguage()
const state = EditorState.create({
doc: text,
extensions: [
basicSetup,
oneDark,
languageCompartment.of(languageExtensions),
EditorView.updateListener.of(update => {
if (update.docChanged) {
content.value = update.state.doc.toString()
}
}),
keymap.of([
{
key: 'Mod-s',
run: () => {
void save()
return true
}
},
indentWithTab
])
]
})
editorView = new EditorView({ state, parent: editorHost.value })
editorView.focus()
}
const save = async () => {
if (saving.value || loading.value) return
saving.value = true
try {
const res = await apiFetch(filesUrl.value, {
method: 'PUT',
body: content.value,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
}
original.value = content.value
store.showToast(`Saved ${filename.value}`)
} catch (err) {
console.error('Save failed', err)
store.showToast(err instanceof Error ? err.message : 'Save failed')
} finally {
saving.value = false
}
}
onMounted(async () => {
activateEditorBindings()
loading.value = true
error.value = ''
try {
const res = await fetch(filesUrl.value, { method: 'HEAD' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const size = Number(res.headers.get('content-length') || '0')
if (size > MAX_SIZE) {
throw new Error(
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
)
}
const textRes = await fetch(filesUrl.value)
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
const text = await textRes.text()
content.value = text
original.value = text
loading.value = false
await nextTick()
await initEditor(text)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load file'
} finally {
if (loading.value) loading.value = false
}
})
onActivated(() => {
activateEditorBindings()
})
onDeactivated(() => {
deactivateEditorBindings()
})
onUnmounted(() => {
deactivateEditorBindings()
editorView?.destroy()
editorView = null
})
</script>
<style scoped>
.text-editor {
display: flex;
flex-direction: column;
height: 100%;
background: #1a1a1a;
color: #ddd;
text-align: left;
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editor-host {
flex: 1;
min-height: 0;
}
.editor-host :deep(.cm-editor) {
flex: 1;
height: 100%;
border: none;
outline: none;
}
.editor-host :deep(.cm-scroller) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
text-align: left;
}
.editor-host :deep(.cm-content) {
padding: 1rem;
text-align: left;
}
.editor-host :deep(.cm-selectionBackground) {
background: var(--soft-color, #146) !important;
}
.editor-host :deep(.cm-focused .cm-selectionBackground) {
background: var(--soft-color, #146) !important;
}
.editor-host :deep(.cm-content ::selection) {
background: var(--soft-color, #146);
}
.editor-host :deep(.cm-content, .cm-gutter) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.editor-host :deep(.cm-line, .cm-gutters, .cm-gutterElement) {
text-align: left;
}
.status {
padding: 2rem;
text-align: center;
font-size: 1rem;
color: #888;
}
.status.error {
color: #f55;
}
</style>
+50 -24
View File
@@ -37,14 +37,14 @@ interface ResultMessage {
} }
// Worker state // Worker state
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0 let currentSearchId = 0
// Search result cache - cleared when documents change // Search result cache - cleared when documents change
interface CacheEntry { interface CacheEntry {
query: string // Normalized query string query: string // Normalized query string
results: WorkerDoc[] // Matched results (up to limit) results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents complete: boolean // True if search scanned all documents
} }
const searchCache: CacheEntry[] = [] const searchCache: CacheEntry[] = []
const MAX_CACHE_SIZE = 10 const MAX_CACHE_SIZE = 10
@@ -53,11 +53,21 @@ const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase) // Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name // Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string { function normalizeHaystack(str: string): string {
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$' return (
'^' +
str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase() +
'$'
)
} }
function normalizeQuery(str: string): string { function normalizeQuery(str: string): string {
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() return str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
} }
// Test if document matches search query // Test if document matches search query
@@ -143,8 +153,12 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
// Slow path: scan all documents // Slow path: scan all documents
const batchSize = 500 const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) { for (
if (currentSearchId !== searchId) return // Superseded let i = 0;
i < recentDocuments.length && results.length < RESULT_LIMIT;
i += batchSize
) {
if (currentSearchId !== searchId) return // Superseded
// Process batch // Process batch
const end = Math.min(i + batchSize, recentDocuments.length) const end = Math.min(i + batchSize, recentDocuments.length)
@@ -175,7 +189,13 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
} }
// Post results to main thread // Post results to main thread
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) { function postResults(
docs: WorkerDoc[],
query: string,
loc: string,
id: number,
done: boolean
) {
const sorted = sortResults(docs, query, loc) const sorted = sortResults(docs, query, loc)
postMessage({ postMessage({
type: 'results', type: 'results',
@@ -188,20 +208,21 @@ function postResults(docs: WorkerDoc[], query: string, loc: string, id: number,
// Sort results by relevance // Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] { function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/' const locsub = loc + '/'
return [...docs].sort((a, b) => ( return [...docs].sort(
// Current folder first (a, b) =>
Number(b.loc === loc) - Number(a.loc === loc) || // Current folder first
// Then subfolders Number(b.loc === loc) - Number(a.loc === loc) ||
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) || // Then subfolders
// Then by location Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
collator.compare(a.loc, b.loc) || // Then by location
// Folders before files collator.compare(a.loc, b.loc) ||
Number(b.dir) - Number(a.dir) || // Folders before files
// Exact name match first Number(b.dir) - Number(a.dir) ||
Number(b.name.includes(query)) - Number(a.name.includes(query)) || // Exact name match first
// Finally by name Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
collator.compare(a.name, b.name) // Finally by name
)) collator.compare(a.name, b.name)
)
} }
// Handle incoming messages // Handle incoming messages
@@ -220,7 +241,12 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
await performSearch(msg.query, msg.loc, msg.id) await performSearch(msg.query, msg.loc, msg.id)
} else { } else {
// Empty query - no results needed // Empty query - no results needed
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage) postMessage({
type: 'results',
docs: [],
id: msg.id,
done: true
} as ResultMessage)
} }
} }
} }
-3
View File
@@ -6,9 +6,6 @@
}, },
{ {
"path": "./tsconfig.app.json" "path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
} }
] ]
} }
+1 -7
View File
@@ -1,12 +1,6 @@
{ {
"extends": "@tsconfig/node18/tsconfig.json", "extends": "@tsconfig/node18/tsconfig.json",
"include": [ "include": ["vite.config.*"],
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"types": ["node", "jsdom"]
}
}
+8 -8
View File
@@ -9,27 +9,27 @@
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying * FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
*/ */
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999" const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || 'http://localhost:8999'
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ['/api'] } = {}) {
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
for (const path of paths) { for (const path of paths) {
proxy[path] = { proxy[path] = {
target: backendUrl, target: backendUrl,
changeOrigin: false, changeOrigin: false,
ws: true, ws: true
} }
} }
return { return {
name: "fastapi-vite", name: 'fastapi-vite',
config: () => ({ config: () => ({
server: { proxy }, server: { proxy },
build: { build: {
outDir: "../cista/frontend-build", outDir: '../cista/frontend-build',
emptyOutDir: true, emptyOutDir: true
}, }
}), })
} }
} }
+14 -16
View File
@@ -1,29 +1,29 @@
import { fileURLToPath, URL } from 'node:url' import { URL, fileURLToPath } from 'node:url'
import fastapiVue from './vite-plugin-fastapi.js' import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
// @ts-ignore // @ts-ignore
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env) // Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }), fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
vue(), vue(),
svgLoader(), // import svg files svgLoader(), // import svg files
Components(), // auto import components Components() // auto import components
], ],
css: { css: {
preprocessorOptions: { preprocessorOptions: {
less: { less: {
modifyVars: {}, modifyVars: {},
javascriptEnabled: true, javascriptEnabled: true
}, }
}, }
}, },
resolve: { resolve: {
alias: { alias: {
@@ -35,11 +35,9 @@ export default defineConfig({
output: { output: {
manualChunks: { manualChunks: {
// Bundle all SVG icons into a single chunk // Bundle all SVG icons into a single chunk
icons: [ icons: ['/src/assets/svg/index.ts']
'/src/assets/svg/index.ts', }
], }
}, }
}, }
},
},
}) })
+50 -4
View File
@@ -41,10 +41,12 @@ dependencies = [
"pillow-heif>=1.1.0", "pillow-heif>=1.1.0",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"pymupdf>=1.26.3", "pymupdf>=1.26.3",
"pyvips[binary]>=3.1.1",
"sanic>=25.12.0", "sanic>=25.12.0",
"setproctitle>=1.3.6", "setproctitle>=1.3.6",
"stream-zip>=0.0.83", "stream-zip>=0.0.83",
"tomli_w>=1.2.0", "tomli_w>=1.2.0",
"tracerite>=2.3.1",
"zstandard>=0.24.0", "zstandard>=0.24.0",
] ]
@@ -75,8 +77,8 @@ docs = [
source = "vcs" source = "vcs"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["cista/frontend-build"] artifacts = ["cista/frontend-build", "cista/docker"]
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/fastapi-vue/buildhook.py"
targets.sdist.include = [ targets.sdist.include = [
"/cista", "/cista",
] ]
@@ -113,10 +115,53 @@ filterwarnings = [
"ignore::DeprecationWarning", "ignore::DeprecationWarning",
] ]
[tool.ruff]
target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["E402"] select = ["ALL"]
ignore = [
"COM812", # formatter compatibility
"ISC001", # formatter compatibility
"ANN001", # legacy codebase: no full runtime annotation coverage yet
"ANN002", # legacy codebase: no full runtime annotation coverage yet
"ANN003", # legacy codebase: no full runtime annotation coverage yet
"ANN201", # legacy codebase: no full runtime annotation coverage yet
"ANN202", # legacy codebase: no full runtime annotation coverage yet
"ANN204", # legacy codebase: no full runtime annotation coverage yet
"ANN205", # legacy codebase: no full runtime annotation coverage yet
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled
"CPY", # copyright notices not wanted in this codebase
"D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized
"D103", # legacy docs not yet standardized
"D104", # legacy docs not yet standardized
"D105", # legacy docs not yet standardized
"D107", # legacy docs not yet standardized
"D200", # legacy docs not yet standardized
"D203", # avoid D203/D211 conflict under ALL selection
"D212", # legacy docs not yet standardized
"D213", # legacy docs not yet standardized
"D400", # legacy docs not yet standardized
"D401", # legacy docs not yet standardized
"D413", # legacy docs not yet standardized
"D415", # legacy docs not yet standardized
"E501", # existing long literals/log strings
"EM101", # exception-message style; low signal for this project
"EM102", # exception-message style; low signal for this project
"INP001", # scripts folder intentionally lacks package markers
"PLR0911", # legacy complexity; keep other correctness rules enabled
"PLR0912", # legacy complexity; keep other correctness rules enabled
"PLR0913", # legacy complexity; keep other correctness rules enabled
"PLR0915", # legacy complexity; keep other correctness rules enabled
"PLR2004", # we like magic numbers (don't remove this suppression)
"PLW0603", # module-level shared state exists in server runtime code
"TRY003", # exception-message strictness too noisy on legacy handlers
]
isort.known-first-party = ["cista"] isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"] per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001", "SLF001"]
per-file-ignores."scripts/*" = ["T20"] per-file-ignores."scripts/*" = ["T20"]
[dependency-groups] [dependency-groups]
@@ -128,6 +173,7 @@ dev = [
"mypy>=1.13.0", "mypy>=1.13.0",
"pre-commit>=4.0.0", "pre-commit>=4.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"sanic-testing>=24.6.0",
] ]
[tool.coverage.run] [tool.coverage.run]
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Benchmark OnlyOffice output formats for office document preview.
Compares:
1. BMP → AVIF (via pyvips)
2. PNG → AVIF (via pyvips)
3. PNG only (no AVIF compression)
Usage:
uv run python scripts/benchmark_onlyoffice_formats.py
"""
from __future__ import annotations
import json
import os
import socket
import socketserver
import subprocess
import sys
import threading
import urllib.request
from collections import defaultdict
from dataclasses import dataclass
from functools import partial
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from time import perf_counter
from urllib.parse import quote
import pyvips
os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ONLYOFFICE_URL = os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
CALLBACK_HOST = os.environ.get("ONLYOFFICE_CALLBACK_HOST", "")
AVIF_QUALITY = 60
AVIF_MAXSIZE = 1024
# Directories to scan
SCAN_DIRS = [
Path("/mnt/c/Users/User/Downloads/DocsMisc"),
Path(
"/mnt/c/Users/User/Downloads/Lattialämmityksen säätöarvot As Oy Helsingin Pulteri D ja E.etc"
),
Path("/mnt/c/Users/User/Downloads/As. Oy Aidasmäentie 16-18 teholaskenta.etc"),
]
OFFICE_EXTS = {
".doc",
".dot",
".docx",
".docm",
".dotx",
".dotm",
".rtf",
".odt",
".ott",
".txt",
".md",
".mhtml",
".mht",
".html",
".htm",
".xml",
".wps",
".wri",
".xls",
".xlsx",
".xlsm",
".xlsb",
".xltx",
".xltm",
".ods",
".ots",
".csv",
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
".odp",
".otp",
}
# ---------------------------------------------------------------------------
# OnlyOffice client (inline to avoid import overhead)
# ---------------------------------------------------------------------------
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args) -> None:
pass
def _get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
return s.getsockname()[1]
def _get_callback_host() -> str:
if CALLBACK_HOST:
return CALLBACK_HOST
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
for line in result.stdout.splitlines():
if "inet " in line:
parts = line.strip().split()
return parts[1].split("/")[0]
except Exception:
return "127.0.0.1"
def _serve_file_temporarily(file_path: Path):
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
def onlyoffice_convert(
file_path: Path, output_type: str, timeout: float = 60.0
) -> bytes:
oo_url = ONLYOFFICE_URL.rstrip("/")
convert_url = f"{oo_url}/ConvertService.ashx"
doc_url, httpd = _serve_file_temporarily(file_path)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
"async": False,
"filetype": suffix,
"key": f"bench_{file_path.stat().st_mtime_ns}_{output_type}",
"outputtype": output_type,
"title": file_path.name,
"url": doc_url,
}
req = urllib.request.Request( # noqa: S310
convert_url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
body = resp.read()
text = body.decode("utf-8", errors="replace")
if "<Error>" in text:
code = text.split("<Error>")[1].split("</Error>")[0]
raise RuntimeError(f"OnlyOffice error {code}")
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
file_url = file_url.replace("&amp;", "&")
with urllib.request.urlopen(file_url, timeout=timeout) as img_resp: # noqa: S310
return img_resp.read()
finally:
httpd.shutdown()
# ---------------------------------------------------------------------------
# Benchmark helpers
# ---------------------------------------------------------------------------
@dataclass
class Result:
name: str
ext: str
oo_time: float
avif_time: float = 0.0
raw_size: int = 0
final_size: int = 0
error: str = ""
def avif_from_buffer(
img_bytes: bytes, quality: int = AVIF_QUALITY, maxsize: int = AVIF_MAXSIZE
) -> tuple[float, bytes]:
t0 = perf_counter()
img = pyvips.Image.new_from_buffer(img_bytes, "")
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
buf = img.write_to_buffer(".avif", Q=quality, effort=0, strip=True)
t1 = perf_counter()
return t1 - t0, buf
def benchmark_file(path: Path) -> list[Result]:
results: list[Result] = []
# 1. BMP → AVIF
try:
t0 = perf_counter()
bmp = onlyoffice_convert(path, "bmp")
t1 = perf_counter()
avif_t, avif_buf = avif_from_buffer(bmp)
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=t1 - t0,
avif_time=avif_t,
raw_size=len(bmp),
final_size=len(avif_buf),
)
)
except Exception as e:
results.append(
Result(
name=path.name, ext=path.suffix.lower(), oo_time=0, error=f"bmp: {e}"
)
)
# 2. PNG → AVIF
try:
t0 = perf_counter()
png = onlyoffice_convert(path, "png")
t1 = perf_counter()
avif_t, avif_buf = avif_from_buffer(png)
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=t1 - t0,
avif_time=avif_t,
raw_size=len(png),
final_size=len(avif_buf),
)
)
except Exception as e:
results.append(
Result(
name=path.name, ext=path.suffix.lower(), oo_time=0, error=f"png: {e}"
)
)
# 3. PNG only
try:
t0 = perf_counter()
png = onlyoffice_convert(path, "png")
t1 = perf_counter()
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=t1 - t0,
avif_time=0.0,
raw_size=len(png),
final_size=len(png),
)
)
except Exception as e:
results.append(
Result(
name=path.name,
ext=path.suffix.lower(),
oo_time=0,
error=f"png-only: {e}",
)
)
return results
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
docs: list[Path] = []
for d in SCAN_DIRS:
if not d.exists():
continue
docs.extend(p for p in d.iterdir() if p.suffix.lower() in OFFICE_EXTS)
docs.sort()
total = len(docs)
print(f"Benchmarking {total} documents against OnlyOffice ({ONLYOFFICE_URL})...\n")
all_results: dict[str, list[Result]] = {
"bmp→avif": [],
"png→avif": [],
"png-only": [],
}
for i, doc in enumerate(docs, 1):
print(f"[{i}/{total}] {doc.name} ...", end=" ", flush=True)
res = benchmark_file(doc)
for r, key in zip(res, all_results.keys(), strict=False):
all_results[key].append(r)
if r.error:
print(f"{key} ERR", end=" ")
else:
print(f"{key} OK", end=" ")
print()
# Summary
print("\n" + "=" * 100)
print(
f"{'Format':<12} {'Count':>6} {'OO ms':>10} {'AVIF ms':>10} {'Total ms':>10} {'Raw KB':>10} {'Final KB':>10} {'Ratio':>8}"
)
print("-" * 100)
for key, results in all_results.items():
ok = [r for r in results if not r.error]
errs = [r for r in results if r.error]
if not ok:
continue
avg_oo = sum(r.oo_time for r in ok) / len(ok) * 1000
avg_avif = sum(r.avif_time for r in ok) / len(ok) * 1000
avg_total = avg_oo + avg_avif
avg_raw = sum(r.raw_size for r in ok) / len(ok) / 1024
avg_final = sum(r.final_size for r in ok) / len(ok) / 1024
ratio = avg_raw / avg_final if avg_final else 0
print(
f"{key:<12} {len(ok):>6} {avg_oo:>10.1f} {avg_avif:>10.1f} {avg_total:>10.1f} {avg_raw:>10.1f} {avg_final:>10.1f} {ratio:>8.1f}x"
)
for r in errs[:3]:
print(f" ERROR: {r.name}: {r.error}")
# Per-extension breakdown
print("\n" + "=" * 100)
print("Per-extension summary (png→avif)")
print(
f"{'Ext':<8} {'Count':>6} {'OO ms':>10} {'AVIF ms':>10} {'Total ms':>10} {'Raw KB':>10} {'Final KB':>10}"
)
print("-" * 100)
by_ext: dict[str, list[Result]] = defaultdict(list)
for r in all_results["png→avif"]:
by_ext[r.ext].append(r)
for ext in sorted(by_ext.keys()):
results = [r for r in by_ext[ext] if not r.error]
if not results:
continue
avg_oo = sum(r.oo_time for r in results) / len(results) * 1000
avg_avif = sum(r.avif_time for r in results) / len(results) * 1000
avg_raw = sum(r.raw_size for r in results) / len(results) / 1024
avg_final = sum(r.final_size for r in results) / len(results) / 1024
print(
f"{ext:<8} {len(results):>6} {avg_oo:>10.1f} {avg_avif:>10.1f} {avg_oo + avg_avif:>10.1f} {avg_raw:>10.1f} {avg_final:>10.1f}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
+20 -12
View File
@@ -16,19 +16,27 @@ Environment:
import argparse import argparse
import asyncio import asyncio
import contextlib
import os import os
import sys import sys
from contextlib import suppress
from pathlib import Path from pathlib import Path
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path) # Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore from devutil import ( # type: ignore[import-not-found]
ProcessGroup,
check_ports_free,
logger,
ready,
setup_vite,
)
from cista import config from cista import config
from cista.serve import parse_listen from cista.serve import parse_listen
DEFAULT_VITE_PORT = 8989
DEFAULT_BACKEND_PORT = 8999 DEFAULT_BACKEND_PORT = 8999
HEALTH = "/api/health?from=devserver.py"
def setup_sanic_backend( def setup_sanic_backend(
@@ -40,11 +48,13 @@ def setup_sanic_backend(
""" """
config.load_config() config.load_config()
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}" listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
url, opts = parse_listen(listen) _url, opts = parse_listen(listen)
port = opts.get("port", DEFAULT_BACKEND_PORT) port = opts.get("port", DEFAULT_BACKEND_PORT)
host = opts.get("host", "localhost") or "localhost" host = opts.get("host", "localhost") or "localhost"
cmd = ["cista", "--dev", "-l", listen] + extra_args # Use the current interpreter/module path so devserver always runs
# workspace source code instead of a potentially stale installed script.
cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen, *extra_args]
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
@@ -57,7 +67,7 @@ async def run_devserver(
logger.warning("Frontend source not found at %s", front) logger.warning("Frontend source not found at %s", front)
raise SystemExit(1) raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "") frontend_url, npm_install, vite = setup_vite(frontend or "", DEFAULT_VITE_PORT)
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args) backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
# Tell vite where to proxy API requests # Tell vite where to proxy API requests
@@ -65,19 +75,17 @@ async def run_devserver(
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
install_proc = await pg.spawn(*npm_install, cwd=str(front)) install_proc = await pg.spawn(*npm_install, cwd=str(front))
await asyncio.sleep(0.2) # reduce message overlap await check_ports_free(frontend_url, backend_url)
await pg.spawn(*sanic_cmd, cwd=str(reporoot)) await pg.spawn(*sanic_cmd, cwd=str(reporoot))
# Wait for both install and backend to be ready # Wait for dependencies to be installed and backend to accept requests
async with asyncio.TaskGroup() as tg: await pg.wait(install_proc, ready(backend_url, path=HEALTH))
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) # Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
await pg.spawn(*vite, cwd=str(front)) await pg.spawn(*vite, cwd=str(front))
def main(): def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run Vite and Cista (Sanic) development servers", description="Run Vite and Cista (Sanic) development servers",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -95,7 +103,7 @@ def main():
help="Cista backend endpoint (default: from config, or :8999)", help="Cista backend endpoint (default: from config, or :8999)",
) )
args, unknown = parser.parse_known_args() args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt): with suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.listen, args.backend, unknown)) asyncio.run(run_devserver(args.listen, args.backend, unknown))
@@ -3,13 +3,16 @@
import sys import sys
from pathlib import Path from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import BuildHookInterface
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build from buildutil import build
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
def initialize(self, version, build_data): """Hatch build hook that builds Vue frontend during package build."""
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
"""Build frontend before package is built."""
super().initialize(version, build_data) super().initialize(version, build_data)
build("frontend") build("frontend")
+99 -60
View File
@@ -7,13 +7,15 @@ import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
MIN_NODE_VERSION = 20
class _PrefixFormatter(logging.Formatter): class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level.""" """Formatter that adds prefix based on log level."""
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.WARNING: if record.levelno >= logging.WARNING:
return f"⚠️ {record.getMessage()}" return f"⚠️ {record.getMessage()}"
return record.getMessage() return record.getMessage()
@@ -30,82 +32,119 @@ def _check_node_version(node_path: str) -> None:
Raises RuntimeError if version is too old or cannot be determined. Raises RuntimeError if version is too old or cannot be determined.
""" """
try: try:
result = subprocess.run( result = subprocess.run( # noqa: S603
[node_path, "--version"], capture_output=True, text=True, check=True [node_path, "--version"],
capture_output=True,
text=True,
check=True,
) )
version_str = result.stdout.strip() version_str = result.stdout.strip()
# Parse version like "v20.10.0" or "v18.17.1" # Parse version like "v20.10.0" or "v18.17.1"
match = re.match(r"v(\d+)", version_str) match = re.match(r"v(\d+)", version_str)
if match: if match:
major_version = int(match.group(1)) major_version = int(match.group(1))
if major_version >= 20: if major_version >= MIN_NODE_VERSION:
return return
raise RuntimeError( msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
f"Node.js {version_str} found, but v20+ required (install with nvm)" raise RuntimeError(msg)
)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError): except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
pass pass
raise RuntimeError("Could not determine Node.js version") msg = "Could not determine Node.js version"
raise RuntimeError(msg)
def _validate_npm_runtime(tool: str) -> bool:
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
return False
try:
_check_node_version(node_path)
except RuntimeError:
return False
return True
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
"""Find runtime specified by JS_RUNTIME environment variable."""
js_runtime_env = os.environ.get("JS_RUNTIME")
if not js_runtime_env:
return None
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 and not runtime_name.startswith(option):
continue
tool = shutil.which(js_runtime)
if tool is None:
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
raise RuntimeError(msg)
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
raise RuntimeError(msg)
_check_node_version(node_path)
return tool, option
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
raise RuntimeError(msg)
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
"""Auto-detect JavaScript runtime from available options."""
node_version_error: RuntimeError | None = None
for option in options:
tool = shutil.which(option)
if not tool:
continue
if option == "npm" and not _validate_npm_runtime(tool):
try:
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path:
_check_node_version(node_path)
except RuntimeError as e:
node_version_error = e
continue
return tool, option
if node_version_error:
raise node_version_error
msg = "Node.js (v20+), Deno or Bun is required but none was found"
raise RuntimeError(msg)
def find_js_runtime() -> tuple[str, str]: def find_js_runtime() -> tuple[str, str]:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect. """Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Raises JSRuntimeError if no suitable runtime is found. Raises RuntimeError if no suitable runtime is found.
""" """
options = ["npm", "deno", "bun"] options = ["npm", "deno", "bun"]
node_version_error: RuntimeError | None = None
# Check for JS_RUNTIME environment variable # Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"): if result := _find_runtime_from_env(options):
js_runtime = js_runtime_env return result
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 # Auto-detect
for option in options: return _auto_detect_runtime(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(): def find_build_tool() -> tuple[list[str], list[str]]:
"""Find JavaScript runtime and construct install/build commands. """Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists. Returns (install_cmd, build_cmd) tuples of command lists.
@@ -143,7 +182,7 @@ def find_dev_tool() -> list[str]:
if name == "bun": if name == "bun":
logger.warning( logger.warning(
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead." "Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
) )
return [tool, *dev_args[name]] return [tool, *dev_args[name]]
@@ -176,16 +215,16 @@ def build(folder: str = "frontend") -> None:
install_cmd, build_cmd = find_build_tool() install_cmd, build_cmd = find_build_tool()
except RuntimeError as e: except RuntimeError as e:
logger.warning(e) logger.warning(e)
raise SystemExit(1) raise SystemExit(1) from None
def run(cmd): def run(cmd: list[str]) -> None:
display_cmd = [Path(cmd[0]).name, *cmd[1:]] display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd)) logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder) subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
try: try:
run(install_cmd) run(install_cmd)
logger.info("") logger.info("")
run(build_cmd) run(build_cmd)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
raise SystemExit(1) raise SystemExit(1) from None
+133 -65
View File
@@ -1,114 +1,156 @@
"""Utilities meant for devserver script, used only in source repository with dev deps.""" """Utilities meant for devserver script, used only in source repository with dev deps."""
import asyncio import asyncio
import subprocess
import sys
from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
import httpx import httpx
from buildutil import find_dev_tool, find_install_tool, logger from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
DEFAULT_VITE_PORT = 8989 if TYPE_CHECKING:
DEFAULT_BACKEND_PORT = 8999 from collections.abc import Coroutine
class ProcessGroup: class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" """Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
def __init__(self): def __init__(self) -> None:
"""Initialize empty process tracking."""
self._procs: list[asyncio.subprocess.Process] = [] self._procs: list[asyncio.subprocess.Process] = []
self._cmds: dict[int, str] = {} # pid -> command name
async def spawn( async def spawn(
self, *cmd: str, cwd: str | None = None self,
*cmd: str,
cwd: str | None = None,
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it.""" """Spawn a subprocess and track it."""
logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]])) cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc) self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc return proc
async def wait(self, proc: asyncio.subprocess.Process) -> None: async def wait(
"""Wait for a process to complete, raise SystemExit(1) on failure.""" self,
if await proc.wait() != 0: *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
logger.warning("Command failed") ) -> None:
raise SystemExit(1) """Wait for processes/coroutines to complete, raise SystemExit on failure."""
async def __aenter__(self): async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
for w in waitables
]
try:
await asyncio.gather(*tasks)
except subprocess.CalledProcessError as e:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None
async def __aenter__(self) -> Self:
"""Enter the async context manager."""
return self return self
async def __aexit__(self, exc_type, *_): async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
"""Wait for one process to exit, terminate others, then wait for all.""" """Wait for one process to exit, terminate others, then wait for all."""
cleanup_task = asyncio.create_task(self._cleanup()) await self._cleanup(immediate=exc_type is not None)
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): async def _cleanup(self, *, immediate: bool = False) -> None:
running = [p for p in self._procs if p.returncode is None] running = [p for p in self._procs if p.returncode is None]
if not running: if not running:
return return
# Wait for any one process to exit if not immediate:
await asyncio.wait( # Wait for any one process to exit
[asyncio.create_task(p.wait()) for p in running], with suppress(asyncio.CancelledError):
return_when=asyncio.FIRST_COMPLETED, await asyncio.wait(
) [asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes # Terminate remaining processes
for p in self._procs: for p in self._procs:
if p.returncode is None: if p.returncode is None:
try: with suppress(ProcessLookupError):
p.terminate() p.terminate()
except ProcessLookupError:
pass
# Wait for all to finish (with overall timeout) # Wait for all to finish (with overall timeout), shielded from cancellation
still_running = [p for p in self._procs if p.returncode is None] still_running = [p for p in self._procs if p.returncode is None]
if still_running: if still_running:
try: with suppress(asyncio.CancelledError):
await asyncio.wait_for( try:
asyncio.gather(*[p.wait() for p in still_running]), await asyncio.shield(
timeout=10, asyncio.wait_for(
) asyncio.gather(*[p.wait() for p in still_running]),
except TimeoutError: timeout=10,
for p in self._procs: ),
if p.returncode is None: )
try: except TimeoutError:
p.kill() for p in self._procs:
except ProcessLookupError: if p.returncode is None:
pass with suppress(ProcessLookupError):
await p.wait() p.kill()
await p.wait()
async def ready(url: str, path: str = "") -> None: async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
async def check(client: httpx.AsyncClient, url: str) -> None:
with suppress(httpx.RequestError):
res = await client.get(url, timeout=0.1)
server = res.headers.get("server", "server")
logger.warning("Conflicting %s already running at %s", server, url)
raise SystemExit(1)
async with httpx.AsyncClient() as client:
await asyncio.gather(*[check(client, url) for url in urls])
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint. """Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately.
Raises SystemExit(1) if server doesn't start in time. Raises SystemExit(1) if server doesn't start in time.
""" """
max_attempts = 50 if not path:
full_url = f"{url}{path}" return
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
for attempt in range(max_attempts): for attempt in range(max_attempts):
try: try:
await client.get(full_url, timeout=1.0) await client.get(f"{url}{path}", timeout=1.0)
logger.info("✓ Backend ready!")
return
except httpx.RequestError: except httpx.RequestError:
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") logger.warning("Backend didn't start in time")
raise SystemExit(1) raise SystemExit(1) from None
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
else:
logger.info("✓ Backend ready!")
return
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]: def setup_vite(
endpoint: str,
default_port: int = 5173,
) -> tuple[str, list[str], list[str]]:
"""Parse frontend endpoint and build commands. """Parse frontend endpoint and build commands.
Returns (url, install_cmd, dev_cmd). Returns (url, install_cmd, dev_cmd).
Raises SystemExit(1) on invalid config. Raises SystemExit(1) on invalid config.
""" """
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT) endpoints = parse_endpoint(endpoint, default_port)
if "uds" in endpoints[0]: if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver") logger.warning("Unix sockets not supported with vite devserver")
@@ -121,18 +163,53 @@ def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
dev_cmd = find_dev_tool() dev_cmd = find_dev_tool()
if host != "localhost": if host != "localhost":
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}") dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
if port != 5173: dev_cmd.append(f"--port={port}")
dev_cmd.append(f"--port={port}")
return f"http://{host}:{port}", install_cmd, dev_cmd return f"http://{host}:{port}", install_cmd, dev_cmd
def setup_fastapi( def setup_fastapi(
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT endpoint: str,
module: str,
default_port: int = 8000,
) -> tuple[str, list[str]]: ) -> tuple[str, list[str]]:
"""Parse backend endpoint and build fastapi dev command. """Parse backend endpoint and build uvicorn command.
Returns (url, cmd). Returns (url, uvicorn_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"]
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
cmd = [
sys.executable,
"-m",
"uvicorn",
module,
f"--host={host}",
f"--port={port}",
"--reload",
f"--reload-dir={reload_dir}",
"--forwarded-allow-ips=*",
]
return f"http://{host}:{port}", cmd
def setup_cli(
cli: str,
endpoint: str,
default_port: int = 8000,
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build CLI command.
Returns (url, cli_cmd).
Raises SystemExit(1) on invalid config. Raises SystemExit(1) on invalid config.
""" """
endpoints = parse_endpoint(endpoint, default_port) endpoints = parse_endpoint(endpoint, default_port)
@@ -144,14 +221,5 @@ def setup_fastapi(
host = endpoints[0]["host"] host = endpoints[0]["host"]
port = endpoints[0]["port"] port = endpoints[0]["port"]
cmd = [ cmd = [cli, f"--listen={host}:{port}"]
"fastapi",
"dev",
"--entrypoint",
module,
"--host",
host,
"--port",
str(port),
]
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
+102
View File
@@ -0,0 +1,102 @@
import argparse
import mimetypes
from pathlib import Path
from cista.preview import process_image_with_timing
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate image previews for all files in a folder, one at a time.",
)
parser.add_argument("folder", type=Path, help="Folder to scan recursively")
parser.add_argument(
"--px",
type=int,
default=1024,
help="Maximum preview dimension in pixels (default: 1024)",
)
parser.add_argument(
"--quality",
type=int,
default=60,
help="AVIF quality passed to preview generation (default: 60)",
)
return parser.parse_args()
def is_image_file(path: Path) -> bool:
mime_type, _ = mimetypes.guess_type(path.name)
return bool(mime_type and mime_type.startswith("image/"))
def main() -> int:
args = parse_args()
folder = args.folder.resolve()
if not folder.is_dir():
raise SystemExit(f"Not a directory: {folder}")
files = sorted(
path for path in folder.rglob("*") if path.is_file() and is_image_file(path)
)
if not files:
print(f"No image files found under {folder}")
return 0
total_files = 0
total_bytes = 0
total_timing_slots: list[float] = []
total_preview_ms: float = 0.0
failures = 0
print(f"Scanning {folder}")
print(f"Generating previews for {len(files)} image files")
for path in files:
total_files += 1
rel = path.relative_to(folder)
try:
preview, timing = process_image_with_timing(
path,
maxsize=args.px,
quality=args.quality,
)
except Exception as exc:
failures += 1
print(f"FAIL {rel} error={exc}")
continue
total_bytes += len(preview)
timings = timing.timings or []
if len(total_timing_slots) < len(timings):
total_timing_slots.extend([0.0] * (len(timings) - len(total_timing_slots)))
for i, value in enumerate(timings):
total_timing_slots[i] += value
total_ms = sum(timings)
total_preview_ms += total_ms
detail = " / ".join(f"{value:.1f}ms" for value in timings)
if detail:
detail = f"timings={detail} total={total_ms:.1f}ms"
else:
detail = f"total={total_ms:.1f}ms"
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
completed = total_files - failures
print()
print("Summary")
print(f" files={total_files}")
print(f" completed={completed}")
print(f" failed={failures}")
print(f" preview_bytes={total_bytes}")
if completed:
if total_timing_slots:
for i, value in enumerate(total_timing_slots, start=1):
print(f" timing{i}_total_ms={value:.1f}")
print(f" preview_total_ms={total_preview_ms:.1f}")
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
return 0 if failures == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+220
View File
@@ -0,0 +1,220 @@
from http.cookies import SimpleCookie
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import auth, config
from cista.app import use_session
from cista.auth import bp as auth_bp
def _set_cookie_headers(response) -> list[str]:
return list(response.headers.get_list("set-cookie"))
def _cookie_header(response, name: str = "cista") -> dict[str, str]:
for header in _set_cookie_headers(response):
cookie = SimpleCookie()
cookie.load(header)
morsel = cookie.get(name)
if morsel is not None and morsel.value:
return {"Cookie": f"{name}={morsel.value}"}
raise AssertionError(f"response did not set cookie {name!r}")
@pytest.fixture
def setup_auth_config(tmp_path: Path):
alice = config.User()
auth.set_password(alice, "secret")
admin = config.User(privileged=True)
auth.set_password(admin, "admin-secret")
config.config = config.Config(
path=tmp_path,
listen=":0",
public=False,
users={"alice": alice, "admin": admin},
)
return tmp_path
@pytest_asyncio.fixture()
async def client(setup_auth_config: Path):
app = Sanic(f"auth-builtins-test-{uuid4().hex}", strict_slashes=True)
@app.on_request
async def load_auth_context(request):
await use_session(request)
app.blueprint(auth_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_restricted_page_renders_login_form_when_logged_out(client):
_, res = await client.get("/auth/restricted/")
assert res.status_code == 200
assert "Authentication Required" in res.text
assert "Username:" in res.text
assert "Password:" in res.text
assert "/auth/login" in res.text
@pytest.mark.asyncio
async def test_restricted_page_with_invalid_session_clears_cookie(client):
_, res = await client.get(
"/auth/restricted/",
headers={"Cookie": "cista=missing-session"},
)
assert res.status_code == 200
assert "Authentication Required" in res.text
assert any("cista=" in header.lower() for header in _set_cookie_headers(res))
@pytest.mark.asyncio
async def test_json_login_sets_session_cookie_and_allows_session_authenticated_api_access(
client,
):
_, res = await client.post(
"/auth/login",
json={"username": "alice", "password": "secret"},
)
assert res.status_code == 200
assert res.json == {"data": {"username": "alice", "privileged": False}}
session_cookie = _cookie_header(res)
_, tokens_res = await client.get("/auth/tokens", headers=session_cookie)
assert tokens_res.status_code == 200
assert tokens_res.json == {"tokens": []}
_, restricted_res = await client.get("/auth/restricted/", headers=session_cookie)
assert restricted_res.status_code == 200
assert "auth-success" in restricted_res.text
@pytest.mark.asyncio
async def test_json_login_rejects_missing_fields(client):
_, res = await client.post(
"/auth/login",
json={"username": "alice"},
)
assert res.status_code == 400
assert "Missing username or password" in res.json["message"]
@pytest.mark.asyncio
async def test_json_login_rejects_invalid_password(client):
_, res = await client.post(
"/auth/login",
json={"username": "alice", "password": "wrong"},
)
assert res.status_code == 403
assert "Invalid password" in res.json["message"]
@pytest.mark.asyncio
async def test_html_login_redirects_and_sets_flash_and_session_cookies(client):
_, res = await client.post(
"/auth/login",
data={"username": "alice", "password": "secret"},
headers={"Accept": "text/html"},
follow_redirects=False,
)
assert res.status_code == 302
assert res.headers["location"] == "/"
headers = _set_cookie_headers(res)
assert any("cista=" in header.lower() for header in headers)
assert any("message=" in header.lower() for header in headers)
@pytest.mark.asyncio
async def test_logout_json_revokes_the_existing_session(client):
_, login_res = await client.post(
"/auth/login",
json={"username": "alice", "password": "secret"},
)
session_cookie = _cookie_header(login_res)
_, logout_res = await client.post("/auth/api/logout", headers=session_cookie)
assert logout_res.status_code == 200
assert logout_res.json == {"message": "Logged out"}
assert any("cista=" in header.lower() for header in _set_cookie_headers(logout_res))
_, retry_res = await client.get("/auth/tokens", headers=session_cookie)
assert retry_res.status_code == 401
@pytest.mark.asyncio
async def test_logout_without_session_reports_not_logged_in(client):
_, res = await client.post("/auth/api/logout")
assert res.status_code == 200
assert res.json == {"message": "Not logged in"}
@pytest.mark.asyncio
async def test_password_change_updates_credentials_and_reissues_session(client):
_, change_res = await client.post(
"/auth/password-change",
json={
"username": "alice",
"password": "secret",
"passwordChange": "fresh-secret",
},
)
assert change_res.status_code == 200
assert change_res.json == {"message": "Password updated"}
session_cookie = _cookie_header(change_res)
_, tokens_res = await client.get("/auth/tokens", headers=session_cookie)
assert tokens_res.status_code == 200
_, old_login_res = await client.post(
"/auth/login",
json={"username": "alice", "password": "secret"},
)
assert old_login_res.status_code == 403
_, new_login_res = await client.post(
"/auth/login",
json={"username": "alice", "password": "fresh-secret"},
)
assert new_login_res.status_code == 200
assert new_login_res.json == {"data": {"username": "alice", "privileged": False}}
@pytest.mark.asyncio
async def test_password_change_rejects_wrong_current_password(client):
_, res = await client.post(
"/auth/password-change",
json={
"username": "alice",
"password": "wrong",
"passwordChange": "fresh-secret",
},
)
assert res.status_code == 403
assert "Invalid password" in res.json["message"]
@pytest.mark.asyncio
async def test_password_change_rejects_missing_fields(client):
_, res = await client.post(
"/auth/password-change",
json={"username": "alice", "password": "secret"},
)
assert res.status_code == 400
assert "Missing username, passwordChange or password" in res.json["message"]
-53
View File
@@ -1,53 +0,0 @@
import tempfile
from pathlib import Path
import pytest
from cista import config
from cista.protocol import Cp, MkDir, Mv, Rename, Rm
@pytest.fixture()
def setup_temp_dir():
with tempfile.TemporaryDirectory() as tmpdirname:
config.config = config.Config(path=Path(tmpdirname), listen=":0")
yield Path(tmpdirname)
def test_mkdir(setup_temp_dir):
cmd = MkDir(path="new_folder")
cmd()
assert (setup_temp_dir / "new_folder").is_dir()
def test_rename(setup_temp_dir):
(setup_temp_dir / "old_name").mkdir()
cmd = Rename(path="old_name", to="new_name")
cmd()
assert not (setup_temp_dir / "old_name").exists()
assert (setup_temp_dir / "new_name").is_dir()
def test_rm(setup_temp_dir):
(setup_temp_dir / "folder_to_remove").mkdir()
cmd = Rm(sel=["folder_to_remove"])
cmd()
assert not (setup_temp_dir / "folder_to_remove").exists()
def test_mv(setup_temp_dir):
(setup_temp_dir / "folder_to_move").mkdir()
(setup_temp_dir / "destination").mkdir()
cmd = Mv(sel=["folder_to_move"], dst="destination")
cmd()
assert not (setup_temp_dir / "folder_to_move").exists()
assert (setup_temp_dir / "destination" / "folder_to_move").is_dir()
def test_cp(setup_temp_dir):
(setup_temp_dir / "folder_to_copy").mkdir()
(setup_temp_dir / "destination").mkdir()
cmd = Cp(sel=["folder_to_copy"], dst="destination")
cmd()
assert (setup_temp_dir / "folder_to_copy").is_dir()
assert (setup_temp_dir / "destination" / "folder_to_copy").is_dir()
+65
View File
@@ -0,0 +1,65 @@
import errno
from pathlib import Path
from typing import NamedTuple
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.api import fileserver
from cista.fileserver import bp as fileserver_bp
class Usage(NamedTuple):
total: int
used: int
free: int
def _low_disk_usage(*args, **kwargs):
return Usage(total=1000, used=900, free=10)
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"disk-space-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
await fileserver.start()
yield app.asgi_client
await fileserver.stop()
@pytest.mark.asyncio
async def test_upload_rejected_when_disk_low(client):
with patch("cista.util.diskspace.shutil.disk_usage", side_effect=_low_disk_usage):
_, res = await client.put("/files/test.txt", data=b"hello world")
assert res.status_code == 507
@pytest.mark.asyncio
async def test_upload_rejected_on_enospc(client):
with patch(
"cista.fileio.os.write",
side_effect=OSError(errno.ENOSPC, "No space left on device"),
):
_, res = await client.put("/files/test.txt", data=b"hello world")
assert res.status_code == 507
+311
View File
@@ -0,0 +1,311 @@
import base64
import hashlib
import hmac
import struct
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from Crypto.Hash import MD4
from sanic import Sanic
from cista import auth, config, session, watching
from cista.app import use_session
from cista.fileserver import bp as fileserver_bp
def _basic_auth(username: str, password: str) -> dict[str, str]:
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {creds}"}
def _ntlm_type1() -> dict[str, str]:
msg = b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0x20080205)
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
def _ntlm_type3(
username: str, password: str, domain: str, challenge: bytes
) -> dict[str, str]:
"""Build an NTLMv2 Type 3 message for testing."""
# NT hash
nt_hash = MD4.new(password.encode("utf-16le")).digest()
# NTLMv2 hash
ntlmv2_hash = hmac.new(
nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5
).digest()
# Build a minimal blob
timestamp = struct.pack("<Q", 0)
client_nonce = b"\x01" * 8
blob = (
b"\x01\x01\x00\x00\x00\x00\x00\x00"
+ timestamp
+ client_nonce
+ b"\x00\x00\x00\x00"
)
# NT proof
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
nt_response = nt_proof + blob
domain_enc = domain.encode("utf-16le")
username_enc = username.encode("utf-16le")
workstation_enc = b""
lm_response = b"" # Empty for NTLMv2
# Build Type 3 message
msg = bytearray()
msg.extend(b"NTLMSSP\x00")
msg.extend(struct.pack("<I", 3))
# Security buffers offsets will be calculated
payload_start = 64
payloads = []
def add_buf(data: bytes):
offset = payload_start + sum(len(p) for p in payloads)
payloads.append(data)
return struct.pack("<HHI", len(data), len(data), offset)
lm_buf = add_buf(lm_response)
nt_buf = add_buf(nt_response)
domain_buf = add_buf(domain_enc)
user_buf = add_buf(username_enc)
ws_buf = add_buf(workstation_enc)
session_buf = add_buf(b"")
msg.extend(lm_buf)
msg.extend(nt_buf)
msg.extend(domain_buf)
msg.extend(user_buf)
msg.extend(ws_buf)
msg.extend(session_buf)
msg.extend(struct.pack("<I", 0x20080205))
for p in payloads:
msg.extend(p)
return {"Authorization": f"NTLM {base64.b64encode(bytes(msg)).decode()}"}
def _session_cookie_header(username: str) -> dict[str, str]:
token = "test-" + username
session.put(token, username)
return {"Cookie": f"cista={token}"}
@pytest.fixture
def setup_storage(tmp_path: Path):
user = config.User()
auth.set_password(user, "secret")
token = config.Token(key="test_token_123", username="alice")
share_ro = config.Token(
key="share_ro_123",
username="alice",
kind="share",
mode="ro",
share_paths=["hello.txt", "docs"],
)
share_rw = config.Token(
key="share_rw_123",
username="alice",
kind="share",
mode="rw",
share_paths=["docs"],
)
share_anon = config.Token(
key="share_anon_123",
kind="share",
mode="ro",
share_paths=["docs"],
)
config.config = config.Config(
path=tmp_path,
listen=":0",
public=False,
users={"alice": user},
tokens={
"test_token_123": token,
"share_ro_123": share_ro,
"share_rw_123": share_rw,
"share_anon_123": share_anon,
},
)
watching.state.root = []
watching.rootpath = tmp_path
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
(tmp_path / "secret.txt").write_text("secret", encoding="utf-8")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
(tmp_path / "docs" / "b.txt").write_text("B", encoding="utf-8")
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
@app.on_request
async def load_auth_context(request):
await use_session(request)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_basic_auth_allows_private_file_access(client):
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("alice", "secret")
)
assert res.status_code == 200
assert res.body == b"hello"
assert "set-cookie" not in res.headers
@pytest.mark.asyncio
async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client):
_, res = await client.get(
"/files/hello.txt",
headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")},
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_options_unauthenticated_allowed(client):
_, res = await client.options("/files/")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_unauthenticated_sends_basic_auth_challenge(client):
_, res = await client.request("PROPFIND", "/files/")
assert res.status_code == 401
assert (
res.headers.get("www-authenticate", "")
.lower()
.startswith('basic realm="cista"')
)
@pytest.mark.asyncio
async def test_basic_auth_with_token(client):
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "test_token_123")
)
assert res.status_code == 200
assert res.body == b"hello"
@pytest.mark.asyncio
async def test_browser_unauthenticated_sends_cookie_challenge(client):
_, res = await client.get(
"/files/", headers={"Accept": "text/html,application/xhtml+xml"}
)
assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
@pytest.mark.asyncio
async def test_ntlm_auth_with_token(client):
# Step 1: request without auth should NOT advertise NTLM
# (we prefer clients use BASIC; NTLM still works if client initiates it)
_, res1 = await client.get("/files/hello.txt")
assert res1.status_code == 401
assert "ntlm" not in res1.headers.get("www-authenticate", "").lower()
# Step 2: client proactively sends Type 1, gets Type 2 challenge
_, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1())
assert res2.status_code == 401
auth_hdr = res2.headers.get("www-authenticate", "")
assert auth_hdr.lower().startswith("ntlm ")
type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1])
challenge = type2_data[24:32]
# Step 3: send Type 3 with token as password
_, res3 = await client.get(
"/files/hello.txt",
headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge),
)
assert res3.status_code == 200
assert res3.body == b"hello"
@pytest.mark.asyncio
async def test_share_token_limits_visible_paths(client):
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 200
assert res.body == b"A"
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 200
assert res.body == b"hello"
_, res = await client.get(
"/files/secret.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_share_token_read_only_blocks_writes(client):
_, res = await client.delete(
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_share_token_rw_allows_writes_in_scope_only(client):
_, res = await client.delete(
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 204
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 404
_, res = await client.delete(
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_anonymous_share_token_requires_public_mode(client):
config.config.public = True
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
)
assert res.status_code == 200
assert res.body == b"A"
config.config.public = False
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
)
assert res.status_code == 401
+176
View File
@@ -0,0 +1,176 @@
"""Path traversal and percent-encoding security tests for the fileserver."""
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
# ---------------------------------------------------------------------------
# %2F — encoded slash should be decoded as a path separator
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_percent2f_decoded_as_path_separator(client, setup_storage: Path):
"""%2F in the URL path is decoded to '/' and treated as a path separator."""
(setup_storage / "sub").mkdir()
(setup_storage / "sub" / "file.txt").write_text("hello", encoding="utf-8")
_, res = await client.get("/files/sub%2Ffile.txt")
assert res.status_code == 200
assert res.text == "hello"
@pytest.mark.asyncio
async def test_mkcol_percent2f_creates_nested_directory(client, setup_storage: Path):
"""%2F in MKCOL path is decoded as a separator, creating nested dirs."""
_, res = await client.request("MKCOL", "/files/parent%2Fchild")
assert res.status_code == 201
assert (setup_storage / "parent" / "child").is_dir()
# ---------------------------------------------------------------------------
# %20 — encoded space in filename
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_percent20_in_filename(client, setup_storage: Path):
(setup_storage / "my file.txt").write_text("spaced", encoding="utf-8")
_, res = await client.get("/files/my%20file.txt")
assert res.status_code == 200
assert res.text == "spaced"
@pytest.mark.asyncio
async def test_mkcol_percent20_in_folder_name(client, setup_storage: Path):
_, res = await client.request("MKCOL", "/files/my%20folder")
assert res.status_code == 201
assert (setup_storage / "my folder").is_dir()
# ---------------------------------------------------------------------------
# Path traversal — .. and encoded variants
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_dotdot_rejected(client):
""".. is path-normalised by the router before reaching the handler."""
_, res = await client.get("/files/..")
assert res.status_code in (400, 404)
@pytest.mark.asyncio
async def test_get_dotdot_segment_rejected(client):
"""Traversal via sub/../.. is path-normalised by the router."""
_, res = await client.get("/files/sub/../..")
assert res.status_code in (400, 404)
@pytest.mark.asyncio
async def test_get_encoded_dotdot_rejected(client):
"""%2E%2E (encoded ..) must be rejected."""
_, res = await client.get("/files/%2E%2E")
assert res.status_code == 400
@pytest.mark.asyncio
async def test_get_encoded_dotdot_segment_rejected(client):
"""%2E%2E used as a segment in a longer path must be rejected."""
_, res = await client.get("/files/sub%2F%2E%2E%2F..%2Fetc%2Fpasswd")
assert res.status_code == 400
@pytest.mark.asyncio
async def test_mkcol_dotdot_rejected(client):
_, res = await client.request("MKCOL", "/files/..")
assert res.status_code in (400, 404)
@pytest.mark.asyncio
async def test_delete_dotdot_rejected(client):
_, res = await client.delete("/files/..")
assert res.status_code in (400, 404)
# ---------------------------------------------------------------------------
# Dot-prefixed filenames (.hidden, ...)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_hidden_file_rejected(client):
"""Names starting with '.' are not allowed."""
_, res = await client.get("/files/.hidden")
assert res.status_code == 400
@pytest.mark.asyncio
async def test_mkcol_hidden_folder_rejected(client):
_, res = await client.request("MKCOL", "/files/.secret")
assert res.status_code == 400
# ---------------------------------------------------------------------------
# Windows-style drive paths (c:/) — safe on Linux, stays inside storage root
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mkcol_windows_drive_path_stays_within_root(client, setup_storage: Path):
"""A Windows-style drive path like 'c:/foo' is treated as a relative path
on Linux and resolves safely inside the storage root."""
_, res = await client.request("MKCOL", "/files/c:/secret")
# Either created inside the storage root (201) or sanitised away (400/404).
# The important assertion: nothing was created outside the storage root.
assert not (Path("/c:") / "secret").exists()
assert not (Path("c:/secret")).exists() # noqa: ASYNC240
if res.status_code == 201:
# Created safely inside tmp storage
assert (setup_storage / "c:" / "secret").is_dir()
@pytest.mark.asyncio
async def test_mkcol_backslash_in_path_sanitised(client, setup_storage: Path):
"""Backslashes are replaced with dashes, not treated as path separators."""
_, res = await client.request("MKCOL", "/files/foo\\..\\bar")
assert res.status_code in (201, 400)
# Must not escape storage root
assert not (setup_storage.parent / "bar").exists()
+242
View File
@@ -0,0 +1,242 @@
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
from cista.protocol import FileEntry
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_mkcol_creates_directory(client, setup_storage: Path):
_, res = await client.request("MKCOL", "/files/new-folder")
assert res.status_code == 201
assert (setup_storage / "new-folder").is_dir()
@pytest.mark.asyncio
async def test_delete_removes_file(client, setup_storage: Path):
file_path = setup_storage / "delete-me.txt"
file_path.write_text("hello", encoding="utf-8")
_, res = await client.delete("/files/delete-me.txt")
assert res.status_code == 204
assert not file_path.exists()
@pytest.mark.asyncio
async def test_post_mv_moves_keys_to_target(client, setup_storage: Path):
(setup_storage / "target").mkdir()
(setup_storage / "alpha.txt").write_text("alpha", encoding="utf-8")
(setup_storage / "beta.txt").write_text("beta", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "alpha.txt", "k-alpha", 0, 5, 0, 1),
FileEntry(1, "beta.txt", "k-beta", 0, 4, 0, 1),
]
_, res = await client.post("/files/target?mv=k-alpha+k-beta")
assert res.status_code == 200
assert res.json["status"] == "ack"
assert not (setup_storage / "alpha.txt").exists()
assert not (setup_storage / "beta.txt").exists()
assert (setup_storage / "target" / "alpha.txt").is_file()
assert (setup_storage / "target" / "beta.txt").is_file()
@pytest.mark.asyncio
async def test_post_cp_copies_keys_to_target(client, setup_storage: Path):
(setup_storage / "target").mkdir()
(setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1),
]
_, res = await client.post("/files/target?cp=k-copy")
assert res.status_code == 200
assert res.json["counts"] == {"cp": 1, "mv": 0}
assert (setup_storage / "copy-me.txt").is_file()
assert (setup_storage / "target" / "copy-me.txt").is_file()
@pytest.mark.asyncio
async def test_post_cp_repeated_params_and_plus_form_are_equivalent(
client,
setup_storage: Path,
):
(setup_storage / "target").mkdir()
(setup_storage / "one.txt").write_text("one", encoding="utf-8")
(setup_storage / "two.txt").write_text("two", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "one.txt", "k-one", 0, 3, 0, 1),
FileEntry(1, "two.txt", "k-two", 0, 3, 0, 1),
]
_, res1 = await client.post("/files/target?cp=k-one&cp=k-two")
assert res1.status_code == 200
assert (setup_storage / "target" / "one.txt").is_file()
assert (setup_storage / "target" / "two.txt").is_file()
(setup_storage / "target" / "one.txt").unlink()
(setup_storage / "target" / "two.txt").unlink()
_, res2 = await client.post("/files/target?cp=k-one+k-two")
assert res2.status_code == 200
assert (setup_storage / "target" / "one.txt").is_file()
assert (setup_storage / "target" / "two.txt").is_file()
@pytest.mark.asyncio
async def test_post_mv_with_to_renames_single_key(
client,
setup_storage: Path,
):
(setup_storage / "dst").mkdir()
(setup_storage / "old-name.txt").write_text("x", encoding="utf-8")
watching.state.root = [
FileEntry(1, "dst", "k-dst", 0, 0, 0, 0),
FileEntry(1, "old-name.txt", "k-old", 0, 1, 0, 1),
]
_, res = await client.post("/files/dst/new-name.txt?mv=k-old")
assert res.status_code == 200
assert not (setup_storage / "old-name.txt").exists()
assert (setup_storage / "dst" / "new-name.txt").is_file()
@pytest.mark.asyncio
async def test_post_cp_single_key_to_file_path(client, setup_storage: Path):
(setup_storage / "dst").mkdir()
(setup_storage / "src.txt").write_text("copy", encoding="utf-8")
watching.state.root = [
FileEntry(1, "dst", "k-dst", 0, 0, 0, 0),
FileEntry(1, "src.txt", "k-src", 0, 4, 0, 1),
]
_, res = await client.post("/files/dst/copied.txt?cp=k-src")
assert res.status_code == 200
assert (setup_storage / "src.txt").is_file()
assert (setup_storage / "dst" / "copied.txt").is_file()
@pytest.mark.asyncio
async def test_post_supports_combined_cp_then_mv(client, setup_storage: Path):
(setup_storage / "target").mkdir()
(setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8")
(setup_storage / "move-me.txt").write_text("move", encoding="utf-8")
watching.state.root = [
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1),
FileEntry(1, "move-me.txt", "k-move", 0, 4, 0, 1),
]
_, res = await client.post("/files/target?cp=k-copy&mv=k-move")
assert res.status_code == 200
assert res.json["counts"] == {"cp": 1, "mv": 1}
assert (setup_storage / "copy-me.txt").is_file()
assert not (setup_storage / "move-me.txt").exists()
assert (setup_storage / "target" / "copy-me.txt").is_file()
assert (setup_storage / "target" / "move-me.txt").is_file()
@pytest.mark.asyncio
async def test_post_rejects_unknown_query_args(client):
_, res = await client.post("/files/?cp=k1&wat=1")
assert res.status_code == 400
assert "unknown query parameter" in res.json["message"].lower()
@pytest.mark.asyncio
async def test_post_requires_query_args(client):
_, res = await client.post("/files/")
assert res.status_code == 400
assert "no query arguments" in res.json["message"].lower()
@pytest.mark.asyncio
async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage: Path):
(setup_storage / "a.txt").write_text("a", encoding="utf-8")
(setup_storage / "b.txt").write_text("b", encoding="utf-8")
(setup_storage / "target.txt").write_text("x", encoding="utf-8")
watching.state.root = [
FileEntry(1, "a.txt", "k-a", 0, 1, 0, 1),
FileEntry(1, "b.txt", "k-b", 0, 1, 0, 1),
FileEntry(1, "target.txt", "k-target", 0, 1, 0, 1),
]
_, cp_res = await client.post("/files/target.txt?cp=k-a+k-b")
_, mv_res = await client.post("/files/target.txt?mv=k-a+k-b")
assert cp_res.status_code == 400
assert "existing directory" in cp_res.json["message"].lower()
assert mv_res.status_code == 400
assert "existing directory" in mv_res.json["message"].lower()
@pytest.mark.asyncio
async def test_post_rejects_directory_to_existing_file_target(
client, setup_storage: Path
):
(setup_storage / "folder").mkdir()
(setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8")
(setup_storage / "existing.txt").write_text("e", encoding="utf-8")
watching.state.root = [
FileEntry(1, "folder", "k-folder", 0, 0, 0, 0),
FileEntry(2, "nested.txt", "k-nested", 0, 1, 0, 1),
FileEntry(1, "existing.txt", "k-existing", 0, 1, 0, 1),
]
_, cp_res = await client.post("/files/existing.txt?cp=k-folder")
_, mv_res = await client.post("/files/existing.txt?mv=k-folder")
assert cp_res.status_code == 400
assert "directory to an existing file" in cp_res.json["message"].lower()
assert mv_res.status_code == 400
assert "directory to an existing file" in mv_res.json["message"].lower()
+106
View File
@@ -0,0 +1,106 @@
from pathlib import Path
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.fileserver import bp as fileserver_bp
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_get_file_full_content(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt")
assert res.status_code == 200
assert res.body == b"hello world"
assert res.headers.get("accept-ranges") == "bytes"
assert res.headers.get("content-length") == "11"
@pytest.mark.asyncio
async def test_head_file_returns_headers_without_body(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.head("/files/hello.txt")
assert res.status_code == 200
assert not res.body
assert res.headers.get("content-length") == "11"
@pytest.mark.asyncio
async def test_get_file_range_start_end(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=1-4"})
assert res.status_code == 206
assert res.body == b"ello"
assert res.headers.get("content-range") == "bytes 1-4/11"
assert res.headers.get("content-length") == "4"
@pytest.mark.asyncio
async def test_get_file_suffix_range(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=-5"})
assert res.status_code == 206
assert res.body == b"world"
assert res.headers.get("content-range") == "bytes 6-10/11"
@pytest.mark.asyncio
async def test_head_file_with_range(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.head("/files/hello.txt", headers={"Range": "bytes=0-4"})
assert res.status_code == 206
assert not res.body
assert res.headers.get("content-range") == "bytes 0-4/11"
assert res.headers.get("content-length") == "5"
@pytest.mark.asyncio
async def test_get_file_unsatisfiable_range_returns_416(client, setup_storage: Path):
path = setup_storage / "hello.txt"
path.write_bytes(b"hello world")
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=99-100"})
assert res.status_code == 416
assert res.headers.get("content-range") == "bytes */11"

Some files were not shown because too many files have changed in this diff Show More