Commit Graph

131 Commits

Author SHA1 Message Date
Dionisio b503e08384 security: fix vulnerabilities 1-7 from security audit
- Fix #1: Share handler IDOR - enforce owner check on share operations
- Fix #2: list_files_query IDOR - bind folder queries to authenticated user
- Fix #3: Dedup handler IDOR - restrict dedup operations to file owner
- Fix #4: Trash handler OptionalAuthUser - require full AuthUser
- Fix #5: Error info leakage - sanitize 500 error responses
- Fix #6: Chunked upload IDOR - bind upload sessions to user_id,
  add verify_session_owner() check on all session operations
- Fix #7: CSP unsafe-inline removal - migrate all inline scripts,
  styles and event handlers to external files, tighten CSP to
  script-src 'self'; style-src 'self'

New files:
  - static/js/core/theme-init.js (render-blocking theme init)
  - static/js/core/sw-register.js (service worker registration)
  - static/css/views/device-verify.css (extracted inline styles)
  - static/js/views/device-verify/device-verify.js (extracted inline script)
2026-03-05 13:15:34 +01:00
Dionisio fdbb2bf60a fix(security): patch critical IDOR & auth vulnerabilities
- Fix logout no-op: extract refresh token from cookie/body (auth_handler)
- Secure all 12 WebDAV handlers with AuthUser + resolve_path_for_user
- Secure all 7 batch handlers with caller_id ownership checks
- Add _owned variants: copy_file_owned, delete_file_owned, get_file_stream_owned, get_folder_owned
- Secure list_files_query: add AuthUser, SQL-level user_id filter, tenant-isolated ETag
- Remove deprecated unscoped resolve_path() and exists() from PathResolverService
- Remove dead list_files handler (unmounted, no auth)
- Add list_files_for_owner (SQL) and list_files_owned across trait chain
2026-03-05 10:30:39 +01:00
Diocrafts ee86c3a128 fix: resolve all clippy warnings and convert integration_tests to custom cfg
- Add type aliases (FileRow, FolderRow, FolderRowPaginated, FolderRowOptUser) to reduce type complexity
- Simplify redundant closures in app_password_handler and webdav_handler
- Remove needless borrow in auth_handler
- Collapse nested if/let chains in login_lockout, webdav_lock, auth, rate_limit
- Box LockEntry in acquire() Err variant to fix large enum variant warning
- Rename DeviceCodeStatus::from_str to parse to avoid should_implement_trait lint
- Add #[allow(clippy::too_many_arguments)] and #[allow(clippy::result_unit_err)] where appropriate
- Convert integration_tests from cargo feature to custom cfg attribute
- Add check-cfg lint config in Cargo.toml for integration_tests cfg
2026-03-04 23:55:08 +01:00
Dionisio 4a60fdc984 fix(security): IDOR protection for file operations
Adds ownership verification at repository, service, and handler layers
for download, rename, move, and delete file operations.

- Repository: get_file_for_owner() with AND user_id= SQL filter
- Service: _owned() methods with verify_owner() fail-closed guard
- Handlers: require AuthUser, delegate to _owned() methods
- Tests: 10 IDOR protection tests (all passing)
- Cleanup: remove dead OptionalUserId import, gate broken pre-existing
  test modules behind integration_tests feature flag
2026-03-04 17:18:39 +01:00
Dionisio 98fb3e6408 fix(security): VULN-01 admin escalation + VULN-02 path traversal hardening
VULN-01 - Admin privilege escalation:
- Harden register() to reject is_admin=true
- Add /api/setup endpoint with setup_token for initial admin creation
- Add SetupAdminDto and setup_token to AppState
- Remove dead code from auth handler

VULN-02 - Path traversal (CVSS ~8.6):
- Solution A+E: Harden StoragePath constructors (from_string, new, join)
  to strip '..' and '.' segments and reject slash injection
- Solution B: resolve_path() now returns Result<PathBuf>, calls
  validate_path() internally, and verifies resolved path stays under root
- Update StoragePort trait signature to return Result<PathBuf, DomainError>
- Remove dead code: FilePathResolutionPort, StorageVerificationPort,
  DirectoryManagementPort (declared but never implemented)
- Add 17 security tests covering traversal attack vectors
2026-03-04 14:14:40 +01:00
Claude 3b0c09cd56 Replace standalone md5 crate with RustCrypto md-5
The standalone `md5` (0.8.0) crate is replaced with `md-5` (0.10) from
the RustCrypto ecosystem, which shares `digest v0.10` with sha2, argon2,
blake2 and other crates already in the dependency tree — eliminating one
redundant implementation.

https://claude.ai/code/session_01V23pGpfNw5ujZvtwRFG6qy
2026-03-03 16:16:44 +00:00
Claude 78c983136f perf: use Arc<str> for repetitive DTO fields to eliminate clone allocations
Replace String with Arc<str> for fields that contain repeated static
values (mime_type, icon_class, icon_special_class, category) in
FileDto, FolderDto, and OptimizedFileContent.

These fields are computed from ~40 static lookup tables and cloned
on every request. With Arc<str>, clone becomes O(1) atomic increment
instead of O(n) heap allocation — saving thousands of allocations/s
under load.

Fields kept as String: id, name, path, folder_id, owner_id
(unique per item, rarely cloned).

Zero API impact — serde serializes Arc<str> identically to String.

https://claude.ai/code/session_01EbAFEfyJNLRmJHmmYDX3Tt
2026-03-03 15:55:15 +00:00
Claude 4bee05c690 merge: resolve conflict in auth_factory.rs with origin/main
Keep custom Result type alias (replacing anyhow) from main while
preserving our removal of unused TokenServicePort import.

https://claude.ai/code/session_01EbAFEfyJNLRmJHmmYDX3Tt
2026-03-03 15:42:28 +00:00
Claude 1b49135ca9 perf: replace dyn trait objects with concrete types to eliminate vtable overhead
Remove async-trait dependency and use native Rust async fn in traits.
Replace Arc<dyn Trait> with Arc<ConcreteType> throughout the codebase
to enable monomorphization and eliminate dynamic dispatch overhead.

Key changes:
- Remove write-behind cache (no implementation existed)
- Fix should_transcode static method call
- Use ContactStorageAdapter directly instead of dyn AddressBookUseCase
- Clean up unused trait imports across services and DI

https://claude.ai/code/session_01EbAFEfyJNLRmJHmmYDX3Tt
2026-03-03 15:36:42 +00:00
Dionisio 6f30ac761f refactor: replace WebDAV LOCK stub with moka-backed lock store (auto-expire TTL) 2026-03-03 11:49:52 +01:00
Dionisio 0cd544ab96 perf: enable BLAKE3 multithreaded hashing (update_rayon) for files >10MB 2026-03-03 11:18:40 +01:00
Dionisio 77fc9bba47 refactor: remove anyhow dependency, replace with thiserror in db.rs and auth_factory.rs 2026-03-03 11:11:39 +01:00
Dionisio efcf88c4d7 style: cargo fmt --all 2026-03-03 01:49:18 +01:00
Dionisio 1df52fd702 security: add IP rate limiting + account lockout on auth endpoints
- Rate limit login (5/min), register (3/hr), refresh (10/min) per IP
- Account lockout after 5 consecutive failed logins (15 min cooldown)
- Fix stored XSS in admin panel (escapeHtml on all user-controlled data)
- All limits configurable via OXICLOUD_RATE_LIMIT_* / OXICLOUD_LOCKOUT_* env vars
- Zero new dependencies (uses existing moka crate for in-memory caches)
- Includes unit tests for lockout service
2026-03-03 01:44:39 +01:00
Dionisio b9bf7ba288 perf(webdav): single UNION ALL query for path resolution
Replace the double-query pattern (get_folder_by_path + get_file_by_path)
across PROPFIND, HEAD, DELETE, MOVE, and COPY handlers with a single
UNION ALL query via PathResolverService.

PG Append node short-circuits on LIMIT 1: if the folder branch matches,
the file branch is never executed. Cuts WebDAV path resolution from
2 round-trips to 1 per request.

Also adds an exists() method using EXISTS subqueries for the Overwrite
header checks in MOVE/COPY (avoids constructing full DTOs).

Legacy double-query fallback retained when PathResolver is unavailable.
2026-03-02 23:40:48 +01:00
Claude b9de342223 fix: resolve CalDAV PROPFIND returning empty property values (#153)
Root cause: XML namespace prefixes (e.g. "D", "C") were not being
resolved to their actual namespace URIs (e.g. "DAV:",
"urn:ietf:params:xml:ns:caldav") during PROPFIND parsing. This caused
all property match arms to fall through to the catch-all, producing
empty XML elements.

Changes:
- Add namespace-aware XML parsing (collect_ns_decls + resolve_name) to
  WebDavAdapter, used by all DAV protocol parsers (WebDAV, CalDAV,
  CardDAV)
- Add /.well-known/caldav -> /caldav/ redirect (RFC 6764)
- Add root /caldav/ PROPFIND response with current-user-principal and
  calendar-home-set discovery properties
- Add /caldav/principals/{username}/ PROPFIND handler (was 500 error)
- Add /caldav/{username}/ user calendar home handler (calendar-home-set
  target)
- Respect Depth header at root: depth 0 returns only root entry, depth
  1+ includes calendar children
- Fix pre-existing TRANSCODE_POOL_THREADS test compilation error
- Add 8 new tests covering namespace resolution, discovery properties,
  and principal responses

https://claude.ai/code/session_01T49VBJSimgo28APxbucHzq
2026-03-02 20:40:41 +00:00
Dionisio d023386ebd perf: add cargo-chef multi-stage Docker build and JWT validation cache
- Replace dummy main.rs caching with cargo-chef planner/cook/build stages
  for granular dependency caching (only invalidates when deps actually change)
- Add BuildKit cache mounts for cargo registry, git checkouts, and target dir
  enabling incremental compilation across Docker builds
- Add BLAKE3-keyed moka cache for JWT token validation results (30s TTL)
  avoiding redundant HMAC-SHA256 verification on repeated requests (~20x faster)
- Include cache hit/miss counters for observability
- Add tests for cache hit behavior and invalid token non-caching
2026-03-02 04:46:13 +01:00
Dionisio Pozo 87ee19d873 Merge pull request #154 from DioCrafts/claude/optimize-performance-FbFK2
Add GIN trigram indexes for substring search performance
2026-03-02 02:30:47 +01:00
Claude 282c3b437c perf: add GIN trigram indexes for ILIKE substring search
Eliminates full table scans on all text search queries by enabling
pg_trgm extension and creating GIN indexes with gin_trgm_ops on
every column used in LIKE/ILIKE '%text%' patterns.

Changes:
- Add pg_trgm extension to schema.sql
- Add 10 GIN trigram indexes: contacts (full_name, first_name,
  last_name, nickname, organization, email::text, phone::text),
  calendar_events (summary), files (name), folders (name)
- Unify all LOWER(col) LIKE patterns to col ILIKE — eliminates
  .to_lowercase() allocation in Rust and ensures index match
- Add minimum 3-char guard on search queries so PostgreSQL uses
  the trigram index instead of falling back to sequential scan
- Add migration 004 with CONCURRENTLY for zero-downtime upgrades

Expected improvement: 100-500x faster text searches on large
datasets (e.g. 100K contacts: ~1.5s → ~3ms).

https://claude.ai/code/session_01QpWV7HXAagdZfyefUw6wKC
2026-03-02 01:28:59 +00:00
Dionisio dd27872a8c perf: replace SHA-256 with BLAKE3 in WebDAV/WOPI, fix JOIN index usage, add LIMIT to favorites, remove dead lru crate, trim tokio features
- WebDAV PUT and WOPI PutFile: replace sha2::Sha256 with blake3::Hasher (~5x faster hashing, compatible with dedup service)
- Fix TEXT↔UUID JOIN anti-pattern in favorites and recent_items repos (enables PK index usage)
- Add LIMIT 500 to get_favorites query to prevent unbounded memory allocation
- Remove unused lru crate from Cargo.toml (superseded by moka)
- Replace tokio features=["full"] with explicit feature list (removes signal, process, test-util)
2026-03-02 02:13:08 +01:00
Dionisio b06206207f perf: optimize hot paths — batch concurrency, pagination, sorting, transcoding, folder ops
- batch_operations.rs: replace join_all with buffer_unordered, Arc<str> for shared IDs, remove redundant clones and dead Semaphore
- folder_db_repository.rs: use COUNT(*) OVER() for single-query pagination; UPDATE RETURNING for rename/move (eliminates extra SELECTs)
- folder_service.rs: remove StorageTransaction wrapper from rename/move — direct repo call (4→2 and 5→3 queries)
- search_service.rs: replace sort_by(to_lowercase) with sort_by_cached_key (N vs 2·N·log₂N allocations)
- image_transcode_service.rs: dynamic rayon pool sizing via available_parallelism() instead of hardcoded 2 threads
- Remove dead transactions module (zero consumers after folder_service refactor)
2026-03-02 01:30:34 +01:00
Dionisio 641b6853ad perf: add socket2 TCP_NODELAY + socket tuning for low-latency responses
- Replace basic TcpListener::bind with socket2 tuned socket
- TCP_NODELAY: disable Nagle's algorithm (-5 to 40ms latency on small responses)
- SO_REUSEADDR: port available immediately after server restart
- SO_REUSEPORT: ready for multi-worker scaling (Linux)
- TCP_KEEPALIVE: detect dead connections within 60s/10s interval
- listen(2048): high backlog for WebDAV connection bursts
- Eliminate redundant create_dir_all calls from upload hot path
2026-03-02 00:12:33 +01:00
Dionisio e2fb29ea60 perf: replace SHA-256 with BLAKE3 + add mimalloc global allocator
- Replace SHA-256 with BLAKE3 (~5x faster) for content-addressable hashing
  in dedup_service, file_handler, file_upload_service, chunked_upload_service
- Add mimalloc as global allocator for 10-30% throughput improvement
- sha2 crate retained only for PKCE (OAuth2 standard requirement)
- BLAKE3 produces 64-char hex hashes (same format), no DB schema changes needed
2026-03-01 21:47:39 +01:00
Dionisio 81987e9321 fix: URL-decode DAV paths with spaces + feat: app passwords for Basic Auth
Bug fix:
- URL-decode paths in extract_webdav_path(), extract_caldav_path(),
  extract_carddav_path() so folders with spaces (e.g. 'My Folder') no
  longer return 404 when accessed via encoded URIs (%20)
- Properly encode href values in PROPFIND/PROPPATCH/LOCK XML responses
- Decode Destination header in MOVE/COPY operations

New feature - App Passwords (API keys for DAV clients):
- POST /api/auth/app-passwords  → create (shows token once)
- GET  /api/auth/app-passwords  → list (prefix only)
- DELETE /api/auth/app-passwords/:id → revoke
- Auth middleware now accepts both Bearer JWT and Basic Auth
- Argon2 hashed, scoped (webdav/caldav/carddav), optional expiry
- Compatible with DAVx5, Thunderbird, rclone, curl

Tested: 12/12 E2E tests pass (create, list, WebDAV/CalDAV/CardDAV
Basic Auth, URL-decode with spaces, wrong password 401, revoke, post-
revoke 401).
2026-03-01 20:34:12 +01:00
Dionisio 48d853360e feat: implement OAuth 2.0 Device Authorization Grant (RFC 8628) for WebDAV/CalDAV/CardDAV
Adds full Device Authorization Grant flow so DAV clients (rclone, etc.)
can authenticate without browser-based OAuth redirects.

New files:
- Domain entity: DeviceCode with status lifecycle (pending/authorized/denied/expired)
- Port: DeviceCodeStoragePort trait (7 async methods)
- DTOs: request/response types for all device auth endpoints
- Repository: DeviceCodePgRepository (PostgreSQL implementation)
- Service: DeviceAuthService (initiate, verify, approve, deny, poll, cleanup)
- Handler: 6 HTTP endpoints (2 public + 4 protected)
- Static: device-verify.html verification page served at /device

Flow:
1. Client POST /api/auth/device/authorize → device_code + user_code
2. User opens /device?code=XXXX in browser, approves
3. Client polls POST /api/auth/device/token → receives JWT tokens
4. Client uses Bearer token with existing WebDAV/CalDAV/CardDAV middleware

Schema: auth.device_codes table + device_code_status enum added to schema.sql

Closes #152
2026-03-01 11:54:43 +01:00
Dionisio 2421724b80 style: cargo fmt --all 2026-02-26 01:02:25 +01:00
Dionisio b05805ee1c fix: resolve clippy warnings (redundant closure, collapsible if) 2026-02-26 00:57:49 +01:00
Dionisio 6df68d3716 style: cargo fmt --all 2026-02-26 00:47:32 +01:00
Dionisio 7e278a0b32 perf: bulk-delete expired trash in 2 SQL queries instead of N+1 loop 2026-02-26 00:42:41 +01:00
Dionisio fb4eaf2cf9 perf: eliminate double disk read in thumbnail generation (read-once buffer) 2026-02-26 00:32:02 +01:00
Dionisio 71727faab4 perf: keep blob_hash in cache across reads (remove one-shot invalidate)
resolve_blob_hash() used to invalidate the cache entry immediately
after the first read, forcing every subsequent request for the same
file into a PG round-trip (~2-4 ms each).  This is especially
costly for video Range Requests (50+ seeks = 49 unnecessary queries).

Fix:
- Remove self.hash_cache.invalidate(file_id) from resolve_blob_hash
- Add self.hash_cache.insert() on the slow path so even requests
  that skip get_file() populate the cache for future reads
- This is safe: blob_hash is content-addressed (SHA-256), immutable
- moka TTI (30 s) + max_capacity (10 000) prevent unbounded growth
- Update 3 unit tests to verify persistent-cache semantics
2026-02-26 00:13:16 +01:00
Dionisio c8d3326cdc perf: replace recursive CTE with ltree <@ in delete_folder
Both delete_folder and delete_folder_permanently used WITH RECURSIVE
to find descendant folders before deleting their files.  This scans
the parent_id chain row-by-row (O(depth × N rows)).

Replace with:
  DELETE FROM storage.files
   WHERE folder_id IN (
       SELECT id FROM storage.folders
        WHERE lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid)
   )

The GiST index on lpath resolves the entire subtree in O(log N),
matching the pattern already used by list_subtree_folders,
stream_files_in_subtree, and search_files_in_subtree.
2026-02-26 00:08:38 +01:00
Dionisio 9f8a6f5177 perf: stream_files_in_subtree — replace Vec<File> with async Stream
Replace list_files_in_subtree (fetch_all → Vec) with stream_files_in_subtree
that returns a Pin<Box<dyn Stream<Item = Result<File/FileDto>>>> backed by a
PostgreSQL cursor via sqlx::fetch().

Changes:
- FileReadPort::stream_files_in_subtree() returns streaming cursor (no default)
- FileRetrievalUseCase::stream_files_in_subtree() maps File→FileDto on the fly
- FileBlobReadRepository: async_stream::try_stream! + sqlx::fetch() cursor
- batch_operations: consume stream into HashMap incrementally
- zip_service: consume stream into HashMap incrementally
- All stubs/mocks updated (return empty stream)

Eliminates:
- Double allocation: Vec<(9-tuple)> + Vec<File> materialized simultaneously
- Unbounded RAM proportional to subtree size (was ~500 bytes × N files)
- Latency: callers blocked until last row fetched from PG

RAM is now O(folders) for the HashMap, not O(files).
2026-02-26 00:07:10 +01:00
Dionisio 1ad7a32a61 perf: batch garbage_collect in 500-row mini-transactions
- Replace single unbounded DELETE FROM storage.blobs WHERE ref_count=0
  with a loop of DELETE...LIMIT 500 batches using ctid sub-select
- Each batch is its own implicit TX (~1-5 ms), preventing:
  · massive row-lock accumulation (was ~200 bytes × N orphans in PG)
  · WAL bloat from a single giant DELETE
  · blocking concurrent uploads on storage.blobs
- Blob files deleted AFTER each batch commits (crash-safe)
- tokio::task::yield_now() between batches to avoid starving uploads
2026-02-25 23:54:09 +01:00
Dionisio 5a1959bf23 perf: eliminate Vec<u8> buffer paths — all uploads now stream to disk
Issue #4 (HIGH): save_file(Vec<u8>) and update_file_content(Vec<u8>)
accepted up to 10 MB of contiguous memory per request. While the main
upload paths already used streaming, the WebDAV compat methods
(create_file, update_file) and the empty-file handler still used the
buffered path, creating a .to_vec() copy.

Changes:
- FileWritePort trait: remove save_file(Vec<u8>) and
  update_file_content(Vec<u8>) — only streaming variants remain
- FileUploadUseCase trait: remove upload_file(Vec<u8>)
- file_upload_service.rs: create_file() and update_file() now spool
  &[u8] to NamedTempFile + Sha256::digest, then delegate to streaming
  path (save_file_from_temp / update_file_streaming)
- file_handler.rs: empty file uploads use upload_file_streaming with
- FileBlobWriteRepository: remove save_file and update_file_content impls
- StubFileWritePort, StubFileUploadUseCase, MockFileRepository: remove
  corresponding dead method impls

Impact: impossible to accidentally use a buffered upload path. All
content goes through streaming with ~256 KB peak RAM. -166 LOC.
2026-02-25 23:41:16 +01:00
Dionisio f9dde6ffff perf: replace RwLock<HashMap> with DashMap in ChunkedUploadService + decouple disk I/O from lock
Issue #3 (CRITICAL): The global RwLock<HashMap> serialised ALL chunk uploads
across all users. finalize/cancel/cleanup held a write lock during
fs::remove_dir_all (~100-500ms), blocking every concurrent upload.

Changes:
- Replace tokio::sync::RwLock<HashMap<String, UploadSession>> with
  dashmap::DashMap (sharded concurrent map, ~64 shards)
- Operations on independent sessions never contend
- finalize_upload_inner: remove from map (µs), THEN delete temp dir
- cancel_upload_inner: same pattern — disk I/O outside lock
- cleanup_loop: collect expired IDs via lock-free iteration, remove
  from map, THEN delete dirs sequentially with no lock held
- upload_chunk_inner: DashMap::get_mut replaces global write lock
- get_status_inner / complete_upload_inner: DashMap::get replaces read lock
- Remove tokio::sync::RwLock import (dead)

Also includes Issue #2 (dedup_service.rs write-first + upsert) from
previous session.

Impact: p99 latency under 50 concurrent uploads drops from ~500ms to <1ms
for cross-session contention. Cleanup loop no longer blocks uploads.
2026-02-25 23:31:51 +01:00
Dionisio 97cf6402e2 fix: resolve all clippy warnings and enforce cargo fmt
- display_helpers: convert module doc-comments to regular comments,
  merge identical text/markdown + text/ branches
- search_service: replace needless range loops with slice-based pagination
- folder_repository, folder_db_repository: collapse nested if statements
- favorites_pg_repository: remove unnecessary borrow on generic arg
- file_blob_read_repository: collapse 6 nested if-let blocks
- file_blob_write_repository: collapse nested if for dedup ref decrement
- chunked_upload_service: use div_ceil(), collapse 2 nested if blocks
- folder_handler: collapse nested if-let for owner check
- webdav_handler: replace 7x io::Error::new(ErrorKind::Other, ..) with
  io::Error::other(..)
- cargo fmt applied to all files

Passes: cargo clippy --all-targets --all-features -- -D warnings
2026-02-25 10:28:34 +01:00
Dionisio 9f692f03c3 Implement dual DB pools (primary + maintenance) and wire services 2026-02-24 19:28:00 +01:00
Dionisio 28966ce28e optimize folder search: SQL-level filtering, user isolation, no in-memory filter; batch cascade trigger 2026-02-24 17:15:36 +01:00
Dionisio 6aa38d0d24 perf: thumbnail semaphore, WOPI streaming, store_bytes guard, spawn_blocking SHA-256
- Issue #1: Add Semaphore(4) + 50MP resolution guard to thumbnail_service
  Bounds peak RAM from 4.8GB (50 uploads) to 384MB (4 concurrent decodes)
- Issue #3: Migrate WOPI PutFile from Bytes to streaming temp file + SHA-256
  RAM per WOPI PUT: ~100MB → ~256KB regardless of file size
- Issue #3: Add 10MB guard in dedup store_bytes (defense-in-depth)
- Issue #5: Move chunked upload assembly to spawn_blocking (sync I/O)
  Frees Tokio workers during SHA-256 hashing (~130ms for 500MB)
- Clean up unused tokio imports (OpenOptions, BufWriter)
2026-02-24 16:11:52 +01:00
Dionisio 71c2cb5edb perf: Arc<AppState>, streaming PROPFIND, spawn_blocking SHA-256
- Issue #4: Wrap AppState in Arc — eliminates 42 Arc::clone + 16 String::clone per request
- Issue #2: Reject Depth:infinity with 403 + streaming XML with paginated DB queries
- Issue #5: Move chunked upload assembly (SHA-256 hash-on-write) to spawn_blocking
- Remove ~270 lines dead code from di.rs (unused builders, Default impl, stubs)
- Clean up unused tokio imports in chunked_upload_service.rs
2026-02-24 15:11:56 +01:00
Dionisio cace61127f perf(cache): use Arc<str> for etag/content_type in ContentCachePort
Replace String with Arc<str> for etag and content_type fields in the
content cache. String::clone() allocates and copies the full string on
every cache hit (O(n)), while Arc<str>::clone() is O(1) — just an atomic
ref-count increment.

This eliminates 2 heap allocations per cache hit on the hottest download
path. At 1000 req/s that is 2000 fewer alloc/dealloc cycles per second.

Changed files:
- cache_ports.rs: trait signatures String → Arc<str>
- file_content_cache.rs: CacheEntry fields, get/put methods, tests
- stubs.rs: StubContentCachePort signatures
- file_retrieval_service.rs: caller creates Arc<str> before put()
2026-02-24 13:22:04 +01:00
Dionisio 962468a1ce perf(dedup): remove blocking std::fs calls from async context
Two fixes in dedup_service.rs:

1. Rename error path (L234): std::fs::remove_file → tokio::fs::remove_file
   Restructured from map_err closure to match block since .await
   cannot be used inside a sync closure.

2. Integrity verify (L691): fused blocking .exists() + async metadata()
   into a single fs::metadata().await call. Eliminates one stat()
   syscall per blob AND removes the only remaining blocking I/O from
   the verify_integrity hot loop (buffer_unordered × VERIFY_CONCURRENCY).
2026-02-24 13:06:40 +01:00
Dionisio 78ae145af5 perf(chunked-upload): hoist 512KB read buffer out of per-chunk loop
causing N × 512KB alloc+memset+dealloc cycles per upload. Moving it
before the loop reuses a single allocation across all chunks.

For a 100-chunk upload this eliminates 99 allocations totalling ~50 MB
of unnecessary memset work.
2026-02-24 13:03:18 +01:00
Dionisio 0986ebf81c perf(transcode): eliminate double buffer copy by accepting Bytes instead of &[u8]
Change ImageTranscodePort::get_transcoded signature from &[u8] to Bytes.
- Rayon closure now receives Bytes::clone() (O(1) ref-count) instead of .to_vec() (~5 MB copy)
- Fallback path returns owned Bytes directly instead of Bytes::from(to_vec()) (~5 MB copy)
- Caller passes content.clone() (O(1)) instead of implicit deref

Saves ~10 MB of allocation per transcode call on a 5 MB image.
2026-02-24 12:48:08 +01:00
Dionisio d907a3eb63 perf(thumbnails): load image once for all 3 sizes in background gen
Rewrite generate_all_sizes_background to use a single spawn_blocking
that decodes the source image once and produces all 3 thumbnail sizes
(Icon 150px, Preview 400px, Large 800px) from the same DynamicImage.

Before: 3× image::open() + 3× JPEG/PNG decode + 3× spawn_blocking
After:  1× image::open() + 1× decode + 3× resize in 1 spawn_blocking

Impact for a 20 MB JPEG (5472×3648):
- Disk I/O: 60 MB → 20 MB (3× reduction)
- CPU decode: ~900 ms → ~300 ms (67% saved)
- Peak RAM (10 concurrent uploads): ~5.4 GB → ~1.8 GB
- spawn_blocking slots: 3 → 1 per upload
2026-02-24 12:41:07 +01:00
Dionisio 538be27110 perf(search): eliminate double COUNT+SELECT with COUNT(*) OVER()
Replace separate COUNT and SELECT queries in paginated search with a
single query using PostgreSQL COUNT(*) OVER() window function.

Affected methods:
- search_files_paginated: collapsed 4 branches × 2 queries into a
  single dynamic query builder with 1 query per call
- search_files_in_subtree: merged COUNT + SELECT into one query

Additionally, search_files_paginated is now a dynamic query builder
(like search_files_in_subtree was) instead of 4 hardcoded branches,
reducing code from ~240 lines to ~80 lines.

Impact:
- DB round-trips per search: 2 → 1 (50% reduction)
- Latency: ~50% lower per paginated search
- Connection pool pressure: halved for search workloads
- Atomicity: count and data from same snapshot (no race)
2026-02-24 12:27:33 +01:00
Dionisio ed433df2af perf(zip): eliminate N+1 queries with ltree bulk subtree fetch
Replace BFS traversal that issued 2 SQL queries per folder (list_files +
list_folders) with 2 total queries using PostgreSQL ltree <@ operator:

1. list_subtree_folders: single GiST-indexed scan for all folders
2. list_files_in_subtree: single GiST-indexed join for all files

Files are grouped by folder_id in a HashMap, then iterated in directory
order (folders pre-sorted by path from SQL).

Changes across 4 architecture layers:
- Domain: FolderRepository::list_subtree_folders (default impl)
- Application ports: FolderUseCase, FileRetrievalUseCase, FileReadPort
- Application services: FolderService, FileRetrievalService passthroughs
- Infrastructure: PG implementations + ZipService rewrite

Query count: O(N) → O(1). Latency for 100-folder tree: ~200 round-trips → 3.
2026-02-24 12:18:38 +01:00
Dionisio 4fc0ab8831 perf: replace buffered decompress_stream with async-compression streaming
- Add async-compression dependency with tokio+gzip features
- Rewrite decompress_stream to use true streaming pipeline:
  Stream<Bytes> → StreamReader → BufReader(64KB) → GzipDecoder → ReaderStream(64KB)
- Memory usage drops from ~4GB (1GB compressed file) to constant ~128KB
- Eliminates OOM risk on large compressed file decompression
2026-02-24 11:47:21 +01:00
Dionisio b628a3a166 perf(issue#4): stream dedup verify_integrity & garbage_collect
- Replace fetch_all() with fetch() streaming cursor in verify_integrity
  so memory stays O(batch=16) instead of O(total_blobs)
- Replace fetch_all() with fetch() streaming cursor in garbage_collect
  so memory stays O(1) instead of O(orphans)
- Add TryStreamExt import for try_next() on cursors

Eliminates OOM risk with millions of blobs — RAM usage is now constant
regardless of table size.
2026-02-24 10:45:38 +01:00