Commit Graph

136 Commits

Author SHA1 Message Date
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 a79700b11c perf(auth): add Semaphore to bound concurrent Argon2 hashes
- Wrap hash_password calls with tokio::sync::Semaphore (max 2 concurrent)
- Prevents Argon2 from monopolizing CPU cores and starving async tasks
- Caps peak RAM from unbounded to ~38 MB for concurrent hash operations
- Uses spawn_blocking via injected PasswordHasherPort to avoid blocking Tokio
2026-02-24 12:18:20 +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
Dionisio b0235e05c8 perf(issue#6): migrate ShareFsRepository to PostgreSQL
- Add storage.shares table with indexes on token, (item_id, item_type), created_by
- Create SharePgRepository with indexed SQL queries and window-function pagination
- Rewire DI to inject SharePgRepository with PgPool instead of config
- Delete legacy share_fs_repository.rs (295 lines of JSON file I/O)
- Remove dead module declaration from repositories/mod.rs

Eliminates O(n) full-file JSON reads/writes, TOCTOU races, and crash
corruption risk. All share operations now use indexed PG queries.
2026-02-24 10:09:49 +01:00
Dionisio cba34056dc perf: remove HTTP cache middleware, add service-level ETags
- Delete cache.rs middleware that buffered entire response bodies (up to 10MB)
  in RAM on every cache miss, defeating streaming and causing memory spikes
- Also buffered non-GET responses unnecessarily via response_map_body()
- Add lightweight ETag support (based on max modified_at + count) to:
  - FolderHandler::list_folder_listing (combined folder+files endpoint)
  - FileHandler::list_files_query (file listing endpoint)
- Both support If-None-Match / 304 Not Modified without any body buffering
- File downloads already had ETag/304 support at handler level
- Service-level caches (FileContentCache, SearchService, ThumbnailService)
  remain unchanged — they handle caching without HTTP body materialization
2026-02-24 09:52:22 +01:00
Dionisio 677b3eafa9 perf: use adaptive filter for thumbnails (Triangle/CatmullRom instead of Lanczos3)
- Icon (150px): Triangle filter (~5x faster)
- Preview (400px): CatmullRom filter (~2.5x faster)
- Large (800px): CatmullRom filter (~2.5x faster)
- Quality difference is imperceptible at these resolutions
2026-02-24 00:44:27 +01:00
Dionisio 12c6914d5d perf(#28): parallelize verify_integrity() with buffer_unordered(16)
Replace sequential blob-by-blob SHA-256 verification with
futures::stream::buffer_unordered(16) to hash up to 16 blobs
concurrently.

Each hash_file() already runs on spawn_blocking, so 16 concurrent
verifications saturate both disk I/O queue and CPU cores.

Before: 10K blobs × 13ms = ~130s (NVMe) — 1 core, 1 I/O in flight
After:  10K blobs / 16 concurrency = ~8s — 16 cores, 16 I/O in flight
2026-02-23 23:43:59 +01:00
Dionisio 44113eabdc perf(#12): push suggest() filtering + LIMIT to SQL
Replace the in-memory approach (list_files → filter in Rust) with
SQL-level ILIKE + relevance ORDER BY + LIMIT.

- Add suggest_files_by_name() to FileReadPort (default impl for stubs)
- Add suggest_folders_by_name() to FolderRepository (default impl)
- Implement efficient SQL in FileBlobReadRepository & FolderDbRepository
  with LOWER(name) LIKE pattern, 3-tier relevance sorting, and LIMIT
- Rewrite SearchService::suggest() to call new methods in parallel

Before: 50K files → ~10 MB transferred, 52K string comparisons
After:  50K files → ~20 rows transferred, index-assisted scan
2026-02-23 23:36:38 +01:00
Dionisio ab0c4476b3 perf: fix thundering herd in ThumbnailService with moka entry()
get_thumbnail() used a check-then-act pattern (get → miss → generate →
insert) that allowed N concurrent requests for the same thumbnail to
each trigger independent CPU-heavy generation (image decode + Lanczos3
resize + WebP encode, 50-500ms each).

Replace with moka's entry().or_insert_with() which guarantees only ONE
init closure runs per key; all concurrent callers coalesce and await
the same computation:
- 90% less CPU under concurrent thumbnail requests
- 90% less peak RAM (1 buffer instead of N)
- 90% fewer redundant disk writes
- Removed dead thumbnail_exists() method (no callers)
2026-02-23 23:22:54 +01:00
Dionisio a162aafd43 perf: replace sync zip crate with async_zip in ZipService
ZipWriter<std::fs::File> performed every write_all() as a blocking
write(2) syscall on the Tokio worker thread, sequestering it for
10-100ms per file (0.8-12s for a 100-file ZIP).

Replace with async_zip::ZipFileWriter backed by a buffered
tokio::fs::File:
- All I/O (headers, deflate chunks, central directory) is fully async
- 256 KB BufWriter minimises syscall count
- Zero Tokio worker blocking during ZIP creation
- Streaming per-chunk writes keep RAM O(1) regardless of archive size
- Removed dead From<zip::result::ZipError> for DomainError impl
- zip crate retained for batch_operations.rs (separate concern)
2026-02-23 23:11:55 +01:00
Dionisio 958836e96b perf: replace blocking std::fs calls with tokio::fs::metadata in PathService
file_exists(), directory_exists(), and ensure_directory() were using
synchronous Path::exists() + is_file()/is_dir() which each perform two
blocking stat(2) syscalls on the Tokio worker thread (0.2-4ms total).

Replace with a single tokio::fs::metadata().await call per method:
- Async: worker thread is never blocked
- 1 syscall instead of 2: metadata() returns file type in one stat(2)
- Proper error propagation for I/O errors (not just silent false)
2026-02-23 22:56:20 +01:00
Dionisio b5652b029d perf: offload MD5 checksum to spawn_blocking in chunked uploads
md5::compute(&data) is CPU-bound (~1.2ms per 5MB chunk) and was
blocking the Tokio worker thread. Move it to the blocking thread-pool
via spawn_blocking so the async worker is freed in ~5µs. Bytes::clone
is O(1) (Arc increment) so no extra copy overhead.
2026-02-23 22:49:34 +01:00
Dionisio 1da284a841 perf: move 512KB I/O buffer from stack to heap in chunked upload assembly
- Async Future size drops from ~525KB to ~5KB
- Eliminates stack overflow risk and reduces work-stealing copy cost
2026-02-23 22:41:50 +01:00
Dionisio 41b5500b77 perf: move persist_progress disk I/O outside write lock in chunked uploads
- Write lock now held only for RAM updates (~microseconds instead of ~ms)
- Bitmask built under lock, written to disk after lock release
- Concurrent uploads across all sessions no longer blocked by disk I/O
- Under 10 concurrent sessions: lock wait drops from ~50ms to ~10µs
2026-02-23 22:32:14 +01:00
Dionisio 363ec33fc2 perf: parallelize WebDAV PROPFIND queries with tokio::join!
- Root PROPFIND: list_folders + list_files now run concurrently
- Sub-folder PROPFIND: list_files + list_folders now run concurrently
- ~50% latency reduction on every PROPFIND operation
- Matches existing pattern used in folder_handler::list_folder_listing
2026-02-23 22:19:41 +01:00
George Wu 6a9554d23f feat: add breadcrumb navigation and refactor SPA view management
- Add breadcrumb navigation with folder hierarchy display
- Create setCurrentSection() helper to centralize view state management
- Derive nav item section dynamically from DOM data-i18n attribute
- Remove remnant /shared page and consolidate to SPA sharedView
- Add search input to sharedView for client-side filtering
- Fix 'Go to Files' button to use switchToFilesView()
2026-02-22 20:20:00 -08:00
Diocrafts 85908311dc perf: findings 6.1, 6.2, 2.6 — async Argon2, moka cache, full streaming migration
- 6.1: PasswordHasherPort now async_trait with spawn_blocking for Argon2
- 6.2: OIDC pending maps migrated from std::sync::Mutex to moka::sync::Cache with TTL
- 2.6: All file download paths migrated to 64KB streaming (get_file_stream / read_blob_stream)
  - WOPI, dedup, batch ZIP, file_retrieval_service consumers migrated
  - WebDAV COPY uses zero-copy dedup (copy_file)
  - Removed dead code: get_file_content, get_file_mmap, read_blob, read_blob_bytes
    from traits, impls, stubs, and mocks (18 files touched)
2026-02-23 00:51:46 +01:00
Diocrafts b501c4052b perf: replace recursive spawn-per-folder search with O(1) ltree queries
- Add search_files_in_subtree() to FileReadPort with ltree-based SQL
- Add list_descendant_folders() to FolderRepository with ltree GiST index
- Implement both in PG repositories (single query per entity type)
- Remove search_parallel() fan-out (O(N) tokio::spawn → 0 spawns)
- Remove passes_file_filter/passes_folder_filter (filtering now in SQL)
- Fix SearchCriteriaDto cache key: JSON serialization → u64 hash (15x faster)
- Fix escaped quote literals in webdav_handler.rs handle_put function
2026-02-23 00:17:40 +01:00
Diocrafts 92e0364a60 fix: OOM protection, lock-free thumbnail cache, OIDC JWKS TTL
- Streaming WebDAV PUT: body spooled to tempfile with incremental SHA-256,
  peak RAM ~64KB regardless of file size (Solution 2)
- RequestBodyLimitLayer (1MB) on CalDAV/CardDAV routers (Solution 3)
- All body::to_bytes(body, usize::MAX) replaced with explicit limits:
  PROPFIND/PROPPATCH/LOCK → 1MB, MKCOL → 4KB
- Added AppError::payload_too_large (HTTP 413)
- Added max_upload_size to StorageConfig (default 10GB, env override)
- New streaming update chain: FileWritePort::update_file_content_from_temp
  → FileUploadUseCase::update_file_streaming
- ThumbnailService: migrated from RwLock<LruCache> to moka::future::Cache
  with weight-based eviction — eliminates lock contention on read hot-path
- OIDC: discovery + JWKS caches now expire after 1 hour (Cached<T> wrapper)
  so IdP key rotation no longer requires server restart
2026-02-22 23:28:03 +01:00
Diocrafts b48f2867ac security(P0): fail-closed when auth is enabled but cannot initialize
Previously, if enable_auth=true but the database or auth services failed
to initialize, the server silently started in PUBLIC mode — all API routes
accessible without authentication. This is a critical fail-open bug.

Fixes (fail-closed behavior):
1. main.rs: DB pool creation failure with enable_auth=true now panics
   instead of falling through with db_pool=None
2. di.rs: create_auth_services() failure now propagates the error via ?
   instead of logging and continuing with auth_services=None
3. main.rs: Added defensive assert! that auth_service is Some when
   enable_auth=true, preventing any future refactor from reintroducing
   the silent degradation

The server will now refuse to start if authentication is configured
but cannot be properly initialized.
2026-02-22 22:40:55 +01:00
Diocrafts 6ad23e0acc fix(perf): replace std::sync::Mutex with moka lock-free cache in async context
Eliminates deadlock risk under concurrent load:
- SearchService: Arc<Mutex<HashMap>> → moka::sync::Cache with automatic TTL + LRU
  - Removed manual cleanup task, TTL checking, eviction logic (~90 lines)
  - get_from_cache/store_in_cache are now single lock-free calls
  - clear_search_cache uses invalidate_all()
- HttpCache: Arc<Mutex<HashMap>> → moka::sync::Cache
  - Removed stats(), cleanup(), evict_oldest() manual methods
  - Removed CacheEntry.timestamp/max_age fields (moka handles internally)
  - Removed start_cache_cleanup_task (moka evicts lazily)
- routes.rs: Removed dead HttpCache instantiation and unused TTL variables

Impact: std::sync::Mutex::lock() blocked Tokio worker threads; N concurrent
requests (N = CPU count) could freeze the entire server. moka::sync::Cache
is lock-free and designed for async runtimes — zero contention.
2026-02-22 22:37:36 +01:00
Diocrafts 5b4cd30e2b fix(zip): stream ZIP to temp file instead of loading entire archive into RAM
Solution C - Hybrid temp-file streaming:
- ZipPort trait now returns NamedTempFile instead of Vec<u8>
- ZipService writes to a temp file via ZipWriter<std::fs::File> (O(1) RAM)
- Files are read in 64KB stream chunks via get_file_stream() instead of get_file_content()
- HTTP response streams the temp file via ReaderStream (never loads full ZIP in memory)
- Temp file auto-deleted on drop after response completes
- Removed dead imports (HeaderName, HeaderValue, Cursor, Read)
2026-02-22 22:29:07 +01:00
Diocrafts 2dd3dc0b54 fix: prevent blob storage leak on folder deletion
- Add PG trigger trg_files_decrement_blob_ref (AFTER DELETE ON storage.files)
  that auto-decrements storage.blobs.ref_count for every deleted file row.
  Covers all paths: explicit DELETE, ON DELETE CASCADE, trash emptying.

- Remove manual remove_reference() call from delete_file() in
  file_blob_write_repository — trigger is now the single source of truth.

- Fix double-decrement bug in FileManagementService::delete_with_cleanup:
  was decrementing ref_count on trash (soft-delete) when the file row still
  existed, causing premature blob GC and potential data corruption on restore.

- Remove dead fields (file_read, dedup_service) from FileManagementService
  and simplify constructors — ref_count fully handled by PG trigger.
2026-02-22 22:07:46 +01:00
Diocrafts fd9e509648 perf: implement findings #2, #3, #17 from architecture audit
- Finding #2: Replace Mutex<HashMap> with moka::sync::Cache in file_blob_read_repository (10K cap, 30s TTI)
- Finding #3: Add chunked upload persistence with session.json + progress.bin bitmask for crash recovery
- Finding #17: Remove manual gzip compression, delegate entirely to tower-http CompressionLayer
- Remove dead code: StubCompressionPort, GzipCompressionService re-export, duplicate response structs
- All 114 tests passing
2026-02-22 14:12:53 +01:00
Dionisio Pozo 0975f1f7ab Merge pull request #141 from gbw/docs/env-file-documentation
docs: add example.env and document all environment variables
2026-02-22 09:48:36 +01:00
Dionisio Pozo 316b48b8e4 Merge pull request #139 from gbw/database_level_filtering
Optimize search service with database-level pagination and improve cache handling
2026-02-22 09:47:09 +01:00
Dionisio Pozo 959337479e Merge pull request #140 from gbw/fix/storage-usage-calculation
fix: correct storage usage calculation by using direct SQL query
2026-02-22 09:45:39 +01:00
George Wu 2c428678b6 docs: add example.env and document all environment variables
- Create example.env with all 31 environment variables documented
- Update docker-compose.yml to use env_file directive
- Update doc/deployment.md with Quick Start and missing variables
- Add OXICLOUD_BASE_URL and WOPI configuration docs
- Update README.md Docker section with .env setup
- Remove duplicate port mapping (incorporates 4577e56)
- Fix server port default from 8085 to 8086
2026-02-21 18:44:38 -08:00
George Wu be290604bf feat(search): add missing trait methods for mock repositories
- Add search_files_paginated and count_files to MockFileRepository in share_service.rs
- Add search_files_paginated and count_files to MockFileRepository in trash_service_test.rs

These methods were added to the FileReadPort trait to support database-level
pagination for search optimization.
2026-02-21 18:01:08 -08:00
George Wu 922f00e339 fix: remove uuid cast from storage usage query
The user_id column in storage.files is varchar, not uuid. The ::uuid cast
was causing a type mismatch error when calculating storage usage.
2026-02-21 17:49:36 -08:00
George Wu 6907f0a057 fix: correct storage usage calculation by using direct SQL query
The original implementation tried to find user's home folder via
file_repository.list_files(None), but this only returns files (not folders)
and folders are in a separate table. This resulted in storage always
showing 0 bytes.

Fixed by using a direct SQL query to sum file sizes by user_id from
storage.files table, which is more efficient (O(1) vs recursive) and
correctly calculates storage for all files owned by the user.
2026-02-21 17:30:32 -08:00
George Wu f3f5c40b6b fix: auto-create home folder when listing root folders returns empty
When a user has no home folder (e.g., legacy users or failed folder
creation during registration), the frontend would get an empty list
from GET /api/folders, leaving userHomeFolderId undefined. This caused
uploads to fail or go to the wrong location.

Now list_folders_for_owner() automatically creates a home folder when:
- Listing root folders (parent_id is None)
- The result is empty

This self-healing approach fixes the issue at the source, ensuring
the frontend always gets a valid userHomeFolderId.
2026-02-21 16:53:08 -08:00
George Wu 95fa648a55 Optimize search service with database-level pagination and improve cache handling
- Add search_files_paginated method to FileReadPort for database-level pagination
- Implement efficient SQL-based search with LIMIT/OFFSET in file_blob_read_repository
- Add relevance scoring (exact match > starts-with > contains)
- Support multiple sort options (name, date, size) with ascending/descending order
- Fix cache expiration handling with proper borrow checker semantics
- Use i64 for SQL LIMIT/OFFSET parameters instead of usize
- Clean up duplicate SQL query builder code in search_files_paginated

This significantly improves search performance for non-recursive queries by:
- Pushing pagination to the database layer
- Avoiding loading all files into memory for filtering
- Supporting database-level sorting
2026-02-21 14:50:53 -08:00
George Wu 46a65c322c Add database level pagination and filtering for efficiency 2026-02-21 14:50:53 -08:00
Dionisio Pozo ef15fe85cb Merge pull request #135 from zjean/feature/oidc-user-identity
Feature/OIDC user identity
2026-02-21 21:08:57 +01:00
George Wu 52a47d4b33 Add email_verified check for OIDC login
- Parse email_verified from ID token and UserInfo endpoint
- Reject OIDC login if email is present and not verified
- Only applies when email is in OIDC claims (not required otherwise)
2026-02-21 11:51:48 -08:00
Jan Wiebe 628f4d6b6a style: apply cargo fmt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:33:54 +01:00
Jan Wiebe 0db9c3cd88 feat(auth): expose auth_provider in UserDto and guard password ops for OIDC users
- Add auth_provider field to UserDto, derived from oidc_provider
  ("local" for password users, provider name for OIDC users)
- Block change_password() for OIDC users with clear error message
- Block admin_reset_password() for OIDC users

Fixes #122, Fixes #123

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:26:21 +01:00
Jan Wiebe 4e2c9d2592 feat(wopi): add WOPI protocol support for collaborative editing
Implement the Web Application Open Platform Interface (WOPI) protocol
to enable collaborative document editing with Collabora Online and
OnlyOffice through OxiCloud.

Backend:
- WOPI token service with HMAC-SHA256 signed access tokens
- WOPI lock service with in-memory lock management and expiry
- WOPI discovery service for auto-detecting editor capabilities
- WOPI HTTP handler: CheckFileInfo, GetFile, PutFile, Lock/Unlock
- File entity extended with owner_id for WOPI file-info responses
- Configuration via WOPI_* environment variables
- Services wired through DI in AppState

Frontend:
- WOPI editor component with modal and new-tab viewing modes
- Context menu integration for opening files in online editors
- Inline viewer integration for document preview

Infrastructure:
- Docker Compose file for local Collabora/OnlyOffice dev setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:39:27 +01:00
Jan Wiebe 807370e194 style: apply cargo fmt formatting to existing codebase
Run `cargo fmt` across all Rust source files to enforce consistent
formatting (import ordering, line wrapping, match arm braces).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:33:18 +01:00
George Wu 283a80a302 fix: display unlimited quota (∞) when storage_quota_bytes = 0
- Added formatQuotaSize() function to display ∞ for unlimited (0) quota
- Added format_quota_size() Rust function matching JavaScript behavior
- Updated quota defaulting logic from '||' to '== null' check
- This allows 0 (unlimited) to pass through while defaulting to 10 GB
  only when the value is null/undefined
- Call sites now use dedicated formatQuotaSize() or format_quota_size()
  instead of options parameter for cleaner API
2026-02-20 19:23:44 -08:00
Diocrafts a1a3bd1b2b fix: folder trash/delete operations & frontend refactoring
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' does not exist)
- Fix folder deletion: delete descendant files before folder to avoid 'duplicate key violates unique constraint idx_files_unique_name_at_root'
- Simplify trash model: only mark the folder as trashed, not child files (implicit trash via parent)
- Update trash_items view: filter to show only top-level trashed items
- Update schema.sql: change files.folder_id FK from ON DELETE SET NULL to ON DELETE CASCADE
- Fix trash view icons: folders and files now show correct visual icons (folder-icon, pdf-icon, etc.) in trash view
- Frontend refactoring: extract inline CSS/JS from admin.html and profile.html into dedicated external files
- Frontend cleanup: replace all inline style attributes with CSS classes
- Frontend cleanup: replace style.display JS
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' d
2026-02-20 12:27:52 +01:00
Dionisio Pozo 27eb7b16e0 Merge pull request #128 from gbw/fix/oidc-env-vars-in-admin-panel 2026-02-20 09:21:18 +01:00
George Wu 5738ddbb54 fix(admin): populate OIDC settings in admin panel from environment variables
- Use load_effective_oidc_config() to get OIDC settings, which applies env var overrides
- Use effective.redirect_uri for callback_url instead of calculated value from server_base_url
- This ensures the admin panel shows the correct OXICLOUD_OIDC_REDIRECT_URI value
2026-02-18 19:41:43 -08:00
George Wu e2a2026c3d fix(admin): populate OIDC settings in admin panel from environment variables
The get_oidc_settings() method was reading directly from the database only,
without applying environment variable overrides. Now it uses load_effective_oidc_config()
which properly applies env var overrides (OXICLOUD_OIDC_*), ensuring the admin
panel accurately reflects the runtime configuration.
2026-02-18 19:25:53 -08:00
George Wu d782d8469c fix: always apply database schema on startup
Previously, the application only applied the schema if auth.users table
didn't exist. This caused issues when upgrading - new schema elements
(like storage.trash_items view) weren't created on existing databases.

Now the schema is applied on every startup. Since schema.sql uses
'IF NOT EXISTS' and 'CREATE OR REPLACE', it's safe to run repeatedly.
2026-02-18 17:56:06 -08:00
Dionisio Pozo 4168e259a5 Merge pull request #125 from gbw/brotli-compression
Enable brotli compression for API and static files
2026-02-18 22:53:18 +01:00
Diocrafts 0d7c8bc019 perf: optimize UI rendering and event lifecycle 2026-02-18 10:53:43 +01:00
George Wu b28aa341b3 Enable brotli compression for API and static files
- Add compression-br feature to tower-http
- Apply CompressionLayer to API routes (JSON responses)
- Apply CompressionLayer to static files (CSS, JS, locales)
- File downloads remain uncompressed (avoid double compression)
2026-02-17 23:00:45 -08:00