Merge pull request #155 from DioCrafts/feature/oidc-webdav
Feature/OIDC webdav
This commit is contained in:
Generated
+56
-34
@@ -71,6 +71,18 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.37"
|
||||
@@ -265,6 +277,20 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -411,6 +437,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -806,12 +838,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1042,7 +1068,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.1.5",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1050,11 +1076,6 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -1467,6 +1488,16 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
@@ -1515,15 +1546,6 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -1567,6 +1589,15 @@ version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1766,6 +1797,7 @@ dependencies = [
|
||||
"async_zip",
|
||||
"axum",
|
||||
"base64",
|
||||
"blake3",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -1781,11 +1813,12 @@ dependencies = [
|
||||
"image",
|
||||
"infer",
|
||||
"jsonwebtoken",
|
||||
"lru",
|
||||
"md5",
|
||||
"mimalloc",
|
||||
"mime_guess",
|
||||
"mockall",
|
||||
"moka",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
"rand_core 0.6.4",
|
||||
"rayon",
|
||||
@@ -1793,6 +1826,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -2535,16 +2569,6 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
@@ -2988,9 +3012,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+5
-2
@@ -6,8 +6,9 @@ default-run = "oxicloud"
|
||||
|
||||
|
||||
[dependencies]
|
||||
mimalloc = { version = "0.1.48", default-features = false }
|
||||
axum = { version = "0.8.8", features = ["multipart", "http1", "tokio", "macros"] }
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs"] }
|
||||
tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["fs"] }
|
||||
bytes = "1.11.1"
|
||||
@@ -36,14 +37,15 @@ rand_core = { version = "0.6", features = ["std", "getrandom"] }
|
||||
hyper = { version = "1.8.1", features = ["full"] }
|
||||
quick-xml = "0.39.0"
|
||||
dotenvy = "0.15.7"
|
||||
lru = "0.16.3"
|
||||
moka = { version = "0.12", features = ["future", "sync"] }
|
||||
http-range-header = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
md5 = "0.8.0"
|
||||
sha2 = "0.10.9"
|
||||
blake3 = "1.8.3"
|
||||
hex = "0.4.3"
|
||||
http-body-util = "0.1.3"
|
||||
percent-encoding = "2.3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||
base64 = "0.22.1"
|
||||
fs2 = "0.4"
|
||||
@@ -52,6 +54,7 @@ infer = "0.19"
|
||||
async-compression = { version = "0.4", features = ["tokio", "gzip"] }
|
||||
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
|
||||
dashmap = "6"
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
+51
-15
@@ -1,32 +1,68 @@
|
||||
# Stage 1: Cache dependencies
|
||||
FROM rust:1.93.0-alpine3.23 AS cacher
|
||||
# syntax=docker/dockerfile:1
|
||||
# ============================================================================
|
||||
# Stage 1: PLANNER — Generate a dependency-only recipe from the full source
|
||||
# ============================================================================
|
||||
# cargo-chef inspects the real project structure (lib.rs + main.rs, features,
|
||||
# build scripts, profile settings) and produces a minimal recipe.json that
|
||||
# fingerprints ONLY dependency-relevant metadata. Source code changes that
|
||||
# don't affect dependencies will NOT invalidate this layer.
|
||||
FROM rust:1.93.0-alpine3.23 AS planner
|
||||
WORKDIR /app
|
||||
RUN cargo install cargo-chef --locked
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src src
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2: COOK — Build all dependencies (cached until recipe.json changes)
|
||||
# ============================================================================
|
||||
# This stage compiles every dependency listed in recipe.json with the exact
|
||||
# same profile, features, and target layout as the real build. Because it
|
||||
# uses BuildKit cache mounts for the cargo registry and git checkouts,
|
||||
# even a full rebuild after `docker system prune` only re-downloads crates
|
||||
# that changed upstream — not the entire registry.
|
||||
FROM rust:1.93.0-alpine3.23 AS cook
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
# Create a minimal project to download and cache dependencies
|
||||
RUN mkdir -p src && \
|
||||
echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src target/release/deps/oxicloud*
|
||||
# Stage 2: Build the application
|
||||
COPY --from=planner /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
# Cook dependencies only — no application source code is present.
|
||||
# BuildKit cache mounts persist the cargo registry and target dir across
|
||||
# builds so incremental recompilation works even for dependency updates.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,target=/app/target,sharing=locked \
|
||||
cargo chef cook --release --recipe-path recipe.json && \
|
||||
# Copy built artifacts out of the cache mount so the next stage can access them
|
||||
cp -r /app/target /app/target-out
|
||||
|
||||
# ============================================================================
|
||||
# Stage 3: BUILD — Compile application source on top of pre-built deps
|
||||
# ============================================================================
|
||||
# Only this layer is invalidated when .rs files change. Dependencies are
|
||||
# already compiled and linked from the cook stage.
|
||||
FROM rust:1.93.0-alpine3.23 AS builder
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make
|
||||
# Copy cached dependencies (only target dir and cargo registry)
|
||||
COPY --from=cacher /app/target target
|
||||
COPY --from=cacher /usr/local/cargo/registry /usr/local/cargo/registry
|
||||
# Copy source and static (login.html is embedded at compile-time via include_str!)
|
||||
# Bring in pre-compiled dependencies from cook
|
||||
COPY --from=cook /app/target-out target
|
||||
COPY --from=cook /usr/local/cargo/registry /usr/local/cargo/registry
|
||||
# Copy project metadata and full source
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src src
|
||||
COPY static static
|
||||
COPY db db
|
||||
# Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx)
|
||||
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
|
||||
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
||||
DATABASE_URL="${DATABASE_URL}" cargo build --release
|
||||
|
||||
# Stage 3: Create minimal final image
|
||||
# ============================================================================
|
||||
# Stage 4: RUNTIME — Minimal production image (~25 MB)
|
||||
# ============================================================================
|
||||
FROM alpine:3.23.3
|
||||
|
||||
# OCI image metadata
|
||||
|
||||
@@ -1,810 +0,0 @@
|
||||
# OxiCloud — Comprehensive Architecture & Performance Audit
|
||||
|
||||
> **Scope**: Full source-level analysis of all layers (domain → infrastructure → application → interfaces).
|
||||
> **Methodology**: Static analysis of every critical `.rs` file. No code changes made.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CRITICAL — `std::sync::Mutex` Blocking the Tokio Runtime](#1-critical--stdsyncmutex-blocking-the-tokio-runtime)
|
||||
2. [CRITICAL — ZIP Service Loads Entire Files into Memory](#2-critical--zip-service-loads-entire-files-into-memory)
|
||||
3. [CRITICAL — Share Repository: JSON File I/O per Operation](#3-critical--share-repository-json-file-io-per-operation)
|
||||
4. [HIGH — Blocking Filesystem Calls in Async Context](#4-high--blocking-filesystem-calls-in-async-context)
|
||||
5. [HIGH — Unbounded Task Spawning in Recursive Search](#5-high--unbounded-task-spawning-in-recursive-search)
|
||||
6. [HIGH — Thumbnail Cache Write-Lock Contention on Reads](#6-high--thumbnail-cache-write-lock-contention-on-reads)
|
||||
7. [HIGH — HTTP Cache Middleware Buffers Entire Response Bodies](#7-high--http-cache-middleware-buffers-entire-response-bodies)
|
||||
8. [MEDIUM — N+1 Queries / Extra Database Round Trips](#8-medium--n1-queries--extra-database-round-trips)
|
||||
9. [MEDIUM — Unnecessary String Allocations in Error Paths](#9-medium--unnecessary-string-allocations-in-error-paths)
|
||||
10. [MEDIUM — Redundant Path String in Domain Entities](#10-medium--redundant-path-string-in-domain-entities)
|
||||
11. [MEDIUM — Unbounded Parallel Tasks in Storage Usage Update](#11-medium--unbounded-parallel-tasks-in-storage-usage-update)
|
||||
12. [MEDIUM — Upload Handler Re-parses Its Own HTTP Response](#12-medium--upload-handler-re-parses-its-own-http-response)
|
||||
13. [LOW — One-Shot Cache Pattern Defeats Caching Purpose](#13-low--one-shot-cache-pattern-defeats-caching-purpose)
|
||||
14. [LOW — Duplicated SQL in Paginated Search](#14-low--duplicated-sql-in-paginated-search)
|
||||
15. [LOW — Sequential Trash Cleanup Without Batching](#15-low--sequential-trash-cleanup-without-batching)
|
||||
16. [LOW — Search Cache Key Serializes Entire DTO to JSON](#16-low--search-cache-key-serializes-entire-dto-to-json)
|
||||
17. [Positive Patterns — What's Done Well](#17-positive-patterns--whats-done-well)
|
||||
18. [Summary Matrix](#18-summary-matrix)
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICAL — `std::sync::Mutex` Blocking the Tokio Runtime
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/application/services/search_service.rs` | 4, 56 | `search_cache: Arc<Mutex<HashMap<…>>>` |
|
||||
| `src/interfaces/middleware/cache.rs` | 14, 51 | `cache: Arc<Mutex<HashMap<…>>>` |
|
||||
| `src/application/services/auth_application_service.rs` | 62–63 | `pending_oidc_flows`, `pending_oidc_tokens` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// search_service.rs:4
|
||||
use std::sync::Mutex;
|
||||
|
||||
// search_service.rs:56
|
||||
search_cache: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
||||
```
|
||||
|
||||
Every call to `.lock()` on a `std::sync::Mutex` across an `.await` boundary **blocks the entire Tokio worker thread**. If all Tokio workers are blocked on the Mutex simultaneously, the runtime deadlocks.
|
||||
|
||||
In `search_service.rs`, `get_from_cache()` and `store_in_cache()` both call `.lock()`, and `store_in_cache()` does eviction work (iteration + removal) while holding the lock. The cleanup task (`start_cache_cleanup_task`) also locks the Mutex inside a `tokio::spawn` future.
|
||||
|
||||
In `cache.rs`, every HTTP GET request passes through `.get()` or `.set()`, each calling `self.cache.lock().unwrap()`. The `evict_oldest()` method sorts all entries by timestamp while the parent lock is held.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: CRITICAL under concurrent load.
|
||||
- Under 50+ concurrent requests, Tokio worker threads park on the Mutex, causing tail-latency spikes (p99 > 100ms) and potential deadlock.
|
||||
- The cleanup tasks also lock, creating periodic contention peaks.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
**Option A — Replace with `tokio::sync::RwLock`** (minimal change):
|
||||
```rust
|
||||
use tokio::sync::RwLock;
|
||||
search_cache: Arc<RwLock<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
||||
```
|
||||
|
||||
**Option B — Replace with `moka` (lock-free, recommended)**:
|
||||
```rust
|
||||
use moka::future::Cache;
|
||||
|
||||
// In SearchService
|
||||
search_cache: Cache<SearchCacheKey, SearchResultsDto>,
|
||||
|
||||
// Construction
|
||||
let search_cache = Cache::builder()
|
||||
.max_capacity(max_cache_size as u64)
|
||||
.time_to_live(Duration::from_secs(cache_ttl))
|
||||
.build();
|
||||
```
|
||||
This eliminates all manual eviction logic and the cleanup task entirely. Already used successfully in `image_transcode_service.rs` and `file_content_cache.rs`.
|
||||
|
||||
For the HTTP cache middleware: consider replacing with `moka::future::Cache<String, CacheEntry>`.
|
||||
|
||||
For the auth service: the OIDC maps are short-lived and low-contention, so `tokio::sync::Mutex` would suffice, or use `dashmap::DashMap`.
|
||||
|
||||
---
|
||||
|
||||
## 2. CRITICAL — ZIP Service Loads Entire Files into Memory
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/services/zip_service.rs` | 209–230 | `add_file_to_zip()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// zip_service.rs: add_file_to_zip()
|
||||
async fn add_file_to_zip(
|
||||
&self,
|
||||
zip: &mut ZipWriter<Cursor<Vec<u8>>>,
|
||||
// ...
|
||||
) -> Result<()> {
|
||||
// Loads ENTIRE file content into memory
|
||||
let content = self.file_service.get_file_content(&file_id).await?;
|
||||
zip.write_all(&content)?;
|
||||
}
|
||||
```
|
||||
|
||||
For a folder download containing N files of size S, memory usage is `O(N × S)` **plus** the ZIP buffer itself. A folder with 100 × 100MB files = 10GB in RAM.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: CRITICAL for large folders. OOM-kill risk in production.
|
||||
- The ZIP buffer (`Cursor<Vec<u8>>`) also holds the entire compressed output in memory.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Use `tokio::io::AsyncRead` + streaming ZIP writer (e.g., `async_zip` crate):
|
||||
```rust
|
||||
// Stream-based approach:
|
||||
let blob_stream = self.dedup_service.read_blob_stream(&blob_hash).await?;
|
||||
// Pipe directly to zip writer without buffering the entire file
|
||||
zip.write_entry_stream(file_name, blob_stream).await?;
|
||||
```
|
||||
Alternatively, use `read_blob_stream()` (which already exists in `DedupService` with 256KB chunks) and write chunks incrementally to the ZIP.
|
||||
|
||||
---
|
||||
|
||||
## 3. CRITICAL — Share Repository: JSON File I/O per Operation
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/repositories/share_fs_repository.rs` | 1–286 | `ShareFsRepository` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// Every read operation:
|
||||
async fn get_share(&self, id: &str) -> Result<…> {
|
||||
let shares = self.read_shares().await?; // Read ENTIRE file
|
||||
shares.into_iter().find(|s| s.id == id) // Linear scan
|
||||
}
|
||||
|
||||
// Every write operation:
|
||||
async fn create_share(&self, share: Share) -> Result<…> {
|
||||
let mut shares = self.read_shares().await?; // Read ENTIRE file
|
||||
shares.push(share);
|
||||
self.write_shares(&shares).await?; // Write ENTIRE file
|
||||
}
|
||||
```
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: CRITICAL for concurrent users.
|
||||
- **Race condition**: Two concurrent `create_share()` calls read the same file, each appends its share, and the second write loses the first share.
|
||||
- **O(n)** per operation — every read scans all shares.
|
||||
- **Blocking I/O**: `tokio::fs::read` / `tokio::fs::write` are async but the entire file is serialized/deserialized on every call.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
**Option A — Migrate to PostgreSQL** (recommended, consistent with other repos):
|
||||
```sql
|
||||
CREATE TABLE storage.shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_id UUID NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_shares_token ON storage.shares(token);
|
||||
```
|
||||
|
||||
**Option B — Add file locking + in-memory index** (minimal change):
|
||||
```rust
|
||||
struct ShareFsRepository {
|
||||
shares: Arc<RwLock<HashMap<String, Share>>>, // In-memory index
|
||||
path: PathBuf,
|
||||
file_lock: tokio::sync::Mutex<()>, // Serialize writes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGH — Blocking Filesystem Calls in Async Context
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/services/path_service.rs` | 137, 148, 165, 172 | `physical_path.exists()`, `.is_file()`, `.is_dir()` |
|
||||
| `src/main.rs` | 60, 64 | `std::fs::create_dir_all()` |
|
||||
| `src/infrastructure/services/dedup_service.rs` | 224 | `std::fs::remove_file()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// path_service.rs — inside async fn file_exists()
|
||||
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
let physical_path = self.resolve_path(storage_path);
|
||||
let exists = physical_path.exists() && physical_path.is_file(); // BLOCKING
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
// Also in directory_exists() and ensure_directory()
|
||||
```
|
||||
|
||||
`Path::exists()`, `.is_file()`, and `.is_dir()` perform synchronous `stat()` syscalls. On network-attached storage (NFS, CIFS) or slow disks, these can take 10–100ms, blocking a Tokio worker.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: HIGH on NFS/CIFS storage; moderate on local SSD.
|
||||
- `path_service.rs` is called by the StoragePort trait used throughout the application.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
```rust
|
||||
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
let physical_path = self.resolve_path(storage_path);
|
||||
match tokio::fs::metadata(&physical_path).await {
|
||||
Ok(meta) => Ok(meta.is_file()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(DomainError::from(e)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `main.rs` (startup-only), the blocking calls are acceptable but could use `tokio::fs::create_dir_all()` for consistency.
|
||||
|
||||
---
|
||||
|
||||
## 5. HIGH — Unbounded Task Spawning in Recursive Search
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/application/services/search_service.rs` | 310–360 | `search_parallel()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
fn search_parallel(…) -> Pin<Box<dyn Future<…> + Send>> {
|
||||
Box::pin(async move {
|
||||
let folders = folder_repo.list_folders(current_folder_id.as_deref()).await?;
|
||||
|
||||
// Spawns one task PER subfolder — NO concurrency limit
|
||||
let mut handles = Vec::with_capacity(folder_dtos.len());
|
||||
for subfolder in &folder_dtos {
|
||||
handles.push(tokio::spawn(async move {
|
||||
Self::search_parallel(fr, fdr, Some(folder_id), crit).await
|
||||
}));
|
||||
}
|
||||
// ...joins all
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For a directory tree of depth D with branching factor B, this spawns `B^D` tasks. A user with 1000 folders in a flat structure spawns 1000 concurrent tasks, each making DB queries.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: HIGH — DB connection pool exhaustion (max 20 connections), Tokio task backlog.
|
||||
- Contrast with `batch_operations.rs` which correctly uses `Semaphore::new(10)`.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
```rust
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
fn search_parallel(
|
||||
semaphore: Arc<Semaphore>,
|
||||
// ... other args
|
||||
) {
|
||||
Box::pin(async move {
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
// ... existing logic, pass semaphore to recursive calls
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Or better: for non-recursive search, the DB-level pagination path is already used. For recursive search, consider a single recursive SQL CTE instead of application-level recursion.
|
||||
|
||||
---
|
||||
|
||||
## 6. HIGH — Thumbnail Cache Write-Lock Contention on Reads
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/services/thumbnail_service.rs` | ~25, 200–280 | `cache: Arc<RwLock<LruCache<…>>>` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// LruCache requires write access on EVERY read (LRU promotion)
|
||||
pub async fn get_thumbnail(&self, …) -> Result<Bytes, …> {
|
||||
// Read from cache — but LRU needs write lock!
|
||||
let cache = self.cache.read().await; // Can't actually use read lock for LRU
|
||||
// ...
|
||||
}
|
||||
|
||||
pub async fn add_to_cache(&self, key: ThumbnailCacheKey, data: Bytes) {
|
||||
let mut current_size = self.current_cache_bytes.write().await; // Lock #1
|
||||
// ... eviction loop also acquires:
|
||||
let mut cache = self.cache.write().await; // Lock #2
|
||||
// TWO write locks held simultaneously
|
||||
}
|
||||
```
|
||||
|
||||
Every cache hit and miss requires a write lock. Under concurrent image requests, this creates a bottleneck.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: HIGH for image-heavy workloads.
|
||||
- Two separate `RwLock` acquisitions in `add_to_cache()` — potential for deadlock if ordering is inconsistent.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Replace with `moka::future::Cache` (already used in `image_transcode_service.rs`):
|
||||
|
||||
```rust
|
||||
use moka::future::Cache;
|
||||
|
||||
pub struct ThumbnailService {
|
||||
cache: Cache<ThumbnailCacheKey, Bytes>, // Lock-free reads, weight-based eviction
|
||||
// Remove current_cache_bytes — moka tracks weight internally
|
||||
}
|
||||
|
||||
// Construction
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(max_cache_bytes as u64)
|
||||
.weigher(|_k: &ThumbnailCacheKey, v: &Bytes| v.len() as u32)
|
||||
.time_to_idle(Duration::from_secs(300))
|
||||
.build();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. HIGH — HTTP Cache Middleware Buffers Entire Response Bodies
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/interfaces/middleware/cache.rs` | 247–259, 450–470 | `cache_middleware()`, `HttpCacheService::call()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// cache.rs: cache_middleware()
|
||||
let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10) // Buffer up to 10MB
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let etag = cache.calculate_etag_for_bytes(&bytes); // Hash all bytes
|
||||
cache.set(cache_key, etag, Some(bytes.clone()), …); // Clone + store
|
||||
```
|
||||
|
||||
Every non-cached GET response is fully buffered to calculate an ETag, even for responses that shouldn't be cached (large file listings, etc.).
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: HIGH — 10MB max buffer per concurrent request × N concurrent requests.
|
||||
- The `bytes.clone()` doubles peak memory per response.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
1. Only cache small responses (check `Content-Length` first).
|
||||
2. Use streaming hash (SHA-256) to compute ETag without buffering.
|
||||
3. Skip caching for responses > 1MB.
|
||||
4. Replace `std::sync::Mutex` backing the cache (see Issue #1).
|
||||
|
||||
---
|
||||
|
||||
## 8. MEDIUM — N+1 Queries / Extra Database Round Trips
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol | Issue |
|
||||
|------|---------|--------|-------|
|
||||
| `src/infrastructure/repositories/pg/folder_db_repository.rs` | ~rename_folder | `rename_folder()` | UPDATE + separate SELECT |
|
||||
| `src/infrastructure/repositories/pg/folder_db_repository.rs` | ~move_folder | `move_folder()` | UPDATE + separate SELECT |
|
||||
| `src/infrastructure/repositories/pg/file_blob_write_repository.rs` | ~lookup_folder_path | `lookup_folder_path()` | Extra query per file write |
|
||||
| `src/infrastructure/repositories/pg/trash_db_repository.rs` | ~clear_trash | `clear_trash()` | 2 separate DELETEs |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// folder_db_repository.rs: rename_folder()
|
||||
async fn rename_folder(&self, id: &str, new_name: &str) -> Result<Folder, DomainError> {
|
||||
// Query 1: UPDATE
|
||||
sqlx::query("UPDATE storage.folders SET name = $1 WHERE id = $2::uuid")
|
||||
.execute(self.pool.as_ref()).await?;
|
||||
|
||||
// Query 2: SELECT (separate round trip)
|
||||
self.get_folder(id).await
|
||||
}
|
||||
```
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: MEDIUM — adds 1–5ms per extra round trip, compounded in batch operations.
|
||||
- `lookup_folder_path()` is called per file write; in batch uploads of N files to the same folder, it makes N identical queries.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
```sql
|
||||
-- Use RETURNING to get the updated row in a single query
|
||||
UPDATE storage.folders SET name = $1
|
||||
WHERE id = $2::uuid
|
||||
RETURNING id::text, name, parent_id::text, path, …
|
||||
```
|
||||
|
||||
For `lookup_folder_path()` in batch operations, cache the folder path for the duration of the batch.
|
||||
|
||||
---
|
||||
|
||||
## 9. MEDIUM — Unnecessary String Allocations in Error Paths
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/domain/errors.rs` | throughout | `DomainError` factory methods |
|
||||
| `src/infrastructure/repositories/pg/*.rs` | throughout | `.map_err(|e| DomainError::internal_error(…, format!("…: {e}")))` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
// domain/errors.rs
|
||||
pub fn not_found(entity_type: &'static str, id: impl Into<String>) -> Self {
|
||||
let entity_id = id.into();
|
||||
Self {
|
||||
message: format!("{} not found: {}", entity_type, entity_id), // ALLOCATION
|
||||
entity_id: Some(entity_id), // ALLOCATION
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every error — even `NotFound` which may be a normal control flow path (e.g., checking if a file exists) — allocates 2 strings via `format!()` and `into()`.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: MEDIUM — hot error paths (404 checks, duplicate detection) trigger allocations.
|
||||
- In batch operations checking 1000 files, this creates thousands of unnecessary allocations.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Use `Cow<'static, str>` for common messages:
|
||||
```rust
|
||||
pub fn not_found(entity_type: &'static str, id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed(""), // Defer formatting to Display impl
|
||||
entity_id: Some(id.into()),
|
||||
kind: ErrorKind::NotFound,
|
||||
entity_type,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DomainError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Format lazily only when displayed
|
||||
write!(f, "{} {}: {}", self.entity_type, self.kind,
|
||||
self.entity_id.as_deref().unwrap_or("unknown"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. MEDIUM — Redundant Path String in Domain Entities
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/domain/entities/file.rs` | ~30–50 | `storage_path: StoragePath` + `path_string: String` |
|
||||
| `src/domain/entities/folder.rs` | ~30–50 | Same pattern |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
pub struct File {
|
||||
storage_path: StoragePath,
|
||||
path_string: String, // Redundant: same data as storage_path.to_string()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Every `File` and `Folder` entity carries both a `StoragePath` (which internally holds `Vec<String>`) **and** a pre-rendered `String` copy. This doubles the path memory per entity.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: MEDIUM — when listing 10,000 files, each path is stored twice.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Remove `path_string` and derive it on demand:
|
||||
```rust
|
||||
impl File {
|
||||
pub fn path_string(&self) -> String {
|
||||
self.storage_path.to_string()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or cache it lazily with `OnceCell<String>` if `.to_string()` is called frequently.
|
||||
|
||||
---
|
||||
|
||||
## 11. MEDIUM — Unbounded Parallel Tasks in Storage Usage Update
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/application/services/storage_usage_service.rs` | 138–165 | `update_all_users_storage_usage()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
|
||||
let users = self.user_repository.list_users(1000, 0).await?;
|
||||
|
||||
let mut update_tasks = Vec::new();
|
||||
for user in users {
|
||||
let service_clone = self.clone();
|
||||
// Spawn one task per user — NO concurrency limit
|
||||
let task = task::spawn(async move {
|
||||
service_clone.update_user_storage_usage(&user_id).await
|
||||
});
|
||||
update_tasks.push(task);
|
||||
}
|
||||
// joins all
|
||||
}
|
||||
```
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: MEDIUM — 1000 users = 1000 concurrent DB queries. DB pool has max 20 connections, so 980 tasks queue, but Tokio task overhead + connection wait time is wasteful.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
```rust
|
||||
use futures::stream::{self, StreamExt};
|
||||
|
||||
stream::iter(users)
|
||||
.map(|user| {
|
||||
let svc = self.clone();
|
||||
async move { svc.update_user_storage_usage(&user.id).await }
|
||||
})
|
||||
.buffer_unordered(10) // Max 10 concurrent
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. MEDIUM — Upload Handler Re-parses Its Own HTTP Response
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/interfaces/api/handlers/file_handler.rs` | ~upload_file_with_thumbnails | `upload_file_with_thumbnails()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
The `upload_file_with_thumbnails` handler calls the upload logic, gets back an HTTP response, then reads the response body back to extract the file ID for thumbnail generation. This means:
|
||||
|
||||
1. Serialize file info → JSON response body
|
||||
2. Read response body → bytes
|
||||
3. Deserialize bytes → file info
|
||||
4. Use file info for thumbnail generation
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: MEDIUM — unnecessary serialize → deserialize round trip per upload.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Call the upload service directly and pass the result to thumbnail generation, instead of going through HTTP serialization:
|
||||
|
||||
```rust
|
||||
let file = upload_service.upload_file(…).await?;
|
||||
thumbnail_service.generate_all_sizes_background(file.id.clone(), path);
|
||||
Ok(Json(FileDto::from(file)))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. LOW — One-Shot Cache Pattern Defeats Caching Purpose
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/repositories/pg/file_blob_read_repository.rs` | ~95–115 | `resolve_blob_hash()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
async fn resolve_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||
// Check moka cache
|
||||
if let Some(hash) = self.hash_cache.get(file_id) {
|
||||
self.hash_cache.invalidate(file_id); // Immediately invalidate!
|
||||
return Ok(hash);
|
||||
}
|
||||
// ... DB query
|
||||
}
|
||||
```
|
||||
|
||||
The hash is cached then immediately invalidated after first use. This means repeated reads of the same file always hit the database.
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: LOW — the pattern only provides "write-behind" benefit (avoiding a DB query between upload and first download). Repeated downloads bypass cache.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Remove the invalidation and let moka's TTI (30s) handle expiry:
|
||||
```rust
|
||||
if let Some(hash) = self.hash_cache.get(file_id) {
|
||||
return Ok(hash); // Let TTI handle expiry
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. LOW — Duplicated SQL in Paginated Search
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/repositories/pg/file_blob_read_repository.rs` | 400–620 | `search_files_paginated()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
Four nearly identical `match` arms containing:
|
||||
- Copy-pasted SQL with minor WHERE clause differences
|
||||
- Each arm has a COUNT query + SELECT query (2 DB round trips per search)
|
||||
- SQL ORDER BY built via `format!()` string interpolation
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: LOW (correctness) to MEDIUM (maintenance burden).
|
||||
- The COUNT query is always executed even when the result set is smaller than the limit (i.e., total count could be inferred).
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Build the query dynamically with a query builder:
|
||||
```rust
|
||||
let mut conditions = vec!["fi.user_id = $1::uuid", "fi.is_trashed = false"];
|
||||
let mut bind_idx = 2;
|
||||
|
||||
if let Some(fid) = folder_id {
|
||||
conditions.push(&format!("fi.folder_id = ${bind_idx}::uuid"));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if let Some(name) = &criteria.name_contains {
|
||||
conditions.push(&format!("LOWER(fi.name) LIKE ${bind_idx}"));
|
||||
bind_idx += 1;
|
||||
}
|
||||
// ... single query construction
|
||||
```
|
||||
|
||||
Use `COUNT(*) OVER()` window function to get total count in a single query:
|
||||
```sql
|
||||
SELECT fi.*, COUNT(*) OVER() as total_count
|
||||
FROM storage.files fi
|
||||
WHERE …
|
||||
ORDER BY … LIMIT $N OFFSET $M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. LOW — Sequential Trash Cleanup Without Batching
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/infrastructure/services/trash_cleanup_service.rs` | 75–95 | `cleanup_expired_items()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
for item in expired_items {
|
||||
trash_service.delete_permanently(&trash_id, &user_id).await; // One at a time
|
||||
}
|
||||
```
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: LOW — cleanup runs periodically in the background, not in the request path.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
Use `futures::stream::buffer_unordered()` for concurrent deletion, or batch-delete at the SQL level:
|
||||
```sql
|
||||
DELETE FROM storage.files WHERE is_trashed = true AND trashed_at < NOW() - INTERVAL '30 days';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. LOW — Search Cache Key Serializes Entire DTO to JSON
|
||||
|
||||
### Location
|
||||
|
||||
| File | Line(s) | Symbol |
|
||||
|------|---------|--------|
|
||||
| `src/application/services/search_service.rs` | 170–180 | `create_cache_key()` |
|
||||
|
||||
### Problematic Pattern
|
||||
|
||||
```rust
|
||||
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> Result<SearchCacheKey> {
|
||||
let criteria_str = serde_json::to_string(criteria).map_err(…)?; // Full JSON serialization
|
||||
Ok(SearchCacheKey {
|
||||
criteria_hash: criteria_str, // Stored as full JSON string, not a hash
|
||||
user_id: user_id.to_string(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The "hash" field is actually the full JSON string, not a hash. This means:
|
||||
1. Full serde serialization per search request
|
||||
2. HashMap key comparison is O(n) on string length
|
||||
3. Unnecessary memory for cache keys
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: LOW — search requests are human-speed, not high-throughput.
|
||||
|
||||
### Fix Sketch
|
||||
|
||||
```rust
|
||||
use std::hash::{Hash, Hasher, DefaultHasher};
|
||||
|
||||
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
criteria.hash(&mut hasher); // Derive Hash on SearchCriteriaDto
|
||||
user_id.hash(&mut hasher);
|
||||
SearchCacheKey(hasher.finish())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Positive Patterns — What's Done Well
|
||||
|
||||
These are worth calling out as **exemplary** implementations:
|
||||
|
||||
| Component | File | Pattern |
|
||||
|-----------|------|---------|
|
||||
| **Image Transcoding** | `image_transcode_service.rs` | Dedicated `rayon` thread pool (not Tokio blocking pool), `moka` lock-free cache, `AtomicU64` stats, fire-and-forget disk cache writes. **Best-in-class design.** |
|
||||
| **File Content Cache** | `file_content_cache.rs` | `moka` weight-based cache, lock-free reads, automatic eviction. Clean. |
|
||||
| **Batch Operations** | `batch_operations.rs` | `Semaphore`-based concurrency control. Correct pattern. |
|
||||
| **Thumbnail Generation** | `thumbnail_service.rs` | Uses `spawn_blocking` for image processing. Correct (but cache should be moka). |
|
||||
| **Compression** | `compression_service.rs` | `spawn_blocking` for CPU-bound gzip. Streaming compress. Correct. |
|
||||
| **Dedup Service** | `dedup_service.rs` | Atomic write via temp+rename, `SELECT FOR UPDATE` for blob refcounting, 2-char hash prefix sharding. Solid CAS implementation. |
|
||||
| **Multi-Tier Download** | `file_retrieval_service.rs` | Write-behind → hot cache + WebP → mmap → streaming. Well-designed tiered strategy. |
|
||||
| **Streaming Upload** | `file_handler.rs` | SHA-256 computed during spool, 512KB BufWriter, pre-allocation hints. Good. |
|
||||
| **DB Pagination** | `file_blob_read_repository.rs` | Non-recursive search uses LIMIT/OFFSET at DB level. Correct. |
|
||||
| **Content Dedup** | `file_blob_write_repository.rs` | `copy_file` uses CTE for zero-copy blob dedup. `update_file_content` uses atomic CTE with `FOR UPDATE`. |
|
||||
|
||||
---
|
||||
|
||||
## 18. Summary Matrix
|
||||
|
||||
| # | Issue | Severity | Impact Area | Effort to Fix |
|
||||
|---|-------|----------|-------------|---------------|
|
||||
| 1 | `std::sync::Mutex` in async | **CRITICAL** | Latency, deadlock | Small (swap to moka) |
|
||||
| 2 | ZIP loads files into memory | **CRITICAL** | OOM risk | Medium (streaming ZIP) |
|
||||
| 3 | Share repo: JSON file I/O | **CRITICAL** | Data loss, O(n) | Medium (migrate to PG) |
|
||||
| 4 | Blocking FS in async | **HIGH** | Latency on slow storage | Small (use tokio::fs) |
|
||||
| 5 | Unbounded search tasks | **HIGH** | DB pool exhaustion | Small (add Semaphore) |
|
||||
| 6 | Thumbnail cache write-lock | **HIGH** | Contention | Small (swap to moka) |
|
||||
| 7 | HTTP cache buffers 10MB | **HIGH** | Memory | Medium (streaming hash) |
|
||||
| 8 | N+1 queries | **MEDIUM** | Latency | Small (use RETURNING) |
|
||||
| 9 | Error string allocations | **MEDIUM** | Allocator pressure | Medium (Cow/lazy) |
|
||||
| 10 | Redundant path string | **MEDIUM** | Memory | Small (remove field) |
|
||||
| 11 | Unbounded storage tasks | **MEDIUM** | DB pool | Small (add Semaphore) |
|
||||
| 12 | Handler re-parses response | **MEDIUM** | CPU waste | Small (refactor) |
|
||||
| 13 | One-shot cache invalidation | **LOW** | Cache miss rate | Trivial |
|
||||
| 14 | Duplicated search SQL | **LOW** | Maintenance | Medium |
|
||||
| 15 | Sequential trash cleanup | **LOW** | Cleanup speed | Small |
|
||||
| 16 | JSON cache key | **LOW** | Minor alloc | Small |
|
||||
|
||||
### Recommended Priority Order
|
||||
|
||||
1. **Issues 1, 2, 3** — Fix immediately. These can cause production incidents (deadlocks, OOM, data loss).
|
||||
2. **Issues 4, 5, 6** — Fix before scaling. These create bottlenecks under load.
|
||||
3. **Issue 7** — Fix when observing memory pressure.
|
||||
4. **Issues 8–12** — Address as part of normal development.
|
||||
5. **Issues 13–16** — Clean up opportunistically.
|
||||
@@ -0,0 +1,99 @@
|
||||
-- ============================================================
|
||||
-- Migration 003: OAuth 2.0 Device Authorization Grant (RFC 8628)
|
||||
-- ============================================================
|
||||
-- Adds the device_codes table to support the Device Authorization
|
||||
-- Grant flow for WebDAV/CalDAV/CardDAV client authentication.
|
||||
--
|
||||
-- Flow:
|
||||
-- 1. Client POSTs to /api/auth/device/authorize → receives device_code + user_code
|
||||
-- 2. User opens verification_uri in browser, authenticates, enters user_code
|
||||
-- 3. Client polls /api/auth/device/token with device_code
|
||||
-- 4. Once approved, client receives access_token + refresh_token
|
||||
-- ============================================================
|
||||
|
||||
-- Device code status enum
|
||||
DO $BODY$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'device_code_status' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
CREATE TYPE auth.device_code_status AS ENUM (
|
||||
'pending', -- Waiting for user to authorize
|
||||
'authorized', -- User approved, tokens ready for polling client
|
||||
'denied', -- User denied the request
|
||||
'expired' -- TTL exceeded without user action
|
||||
);
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
-- Device authorization codes table
|
||||
CREATE TABLE IF NOT EXISTS auth.device_codes (
|
||||
-- Unique row ID
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
|
||||
-- RFC 8628 §3.2: device_code — long opaque token sent to the client for polling
|
||||
device_code VARCHAR(128) UNIQUE NOT NULL,
|
||||
|
||||
-- RFC 8628 §3.2: user_code — short human-readable code shown on the client
|
||||
-- and entered by the user on the verification page (e.g. "ABCD-1234")
|
||||
user_code VARCHAR(16) UNIQUE NOT NULL,
|
||||
|
||||
-- Name/description of the client requesting access (shown to user)
|
||||
client_name VARCHAR(255) NOT NULL DEFAULT 'Unknown Client',
|
||||
|
||||
-- Comma-separated scopes requested (e.g. "webdav,caldav,carddav")
|
||||
scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav',
|
||||
|
||||
-- Current status of the device flow
|
||||
status auth.device_code_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- User who authorized the request (NULL until status = 'authorized')
|
||||
user_id VARCHAR(36) REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Tokens generated after authorization (NULL until status = 'authorized')
|
||||
-- Stored encrypted/hashed depending on sensitivity
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
|
||||
-- RFC 8628 §3.2: verification_uri — full URL the user must visit
|
||||
verification_uri TEXT NOT NULL,
|
||||
|
||||
-- RFC 8628 §3.2: verification_uri_complete — URL with user_code pre-filled
|
||||
verification_uri_complete TEXT,
|
||||
|
||||
-- RFC 8628 §3.2: expires_in — encoded as an absolute timestamp
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
|
||||
-- RFC 8628 §3.2: interval — minimum polling interval in seconds
|
||||
poll_interval_secs INTEGER NOT NULL DEFAULT 5,
|
||||
|
||||
-- Last time the client polled (for slow_down enforcement)
|
||||
last_poll_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
authorized_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Index for client polling by device_code (hot path)
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_device_code
|
||||
ON auth.device_codes(device_code);
|
||||
|
||||
-- Index for user verification page lookup by user_code
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_user_code
|
||||
ON auth.device_codes(user_code)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Index for cleanup of expired entries
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_expires_at
|
||||
ON auth.device_codes(expires_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Index for user's authorized devices
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_user_id
|
||||
ON auth.device_codes(user_id)
|
||||
WHERE status = 'authorized';
|
||||
|
||||
COMMENT ON TABLE auth.device_codes IS 'OAuth 2.0 Device Authorization Grant (RFC 8628) codes for DAV client authentication';
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migration 004: Add GIN trigram indexes for ILIKE/LIKE substring search
|
||||
--
|
||||
-- Eliminates full table scans on text search queries by enabling
|
||||
-- PostgreSQL's pg_trgm extension and creating GIN indexes with
|
||||
-- gin_trgm_ops on all columns used in ILIKE / LIKE '%text%' patterns.
|
||||
--
|
||||
-- CONCURRENTLY is used so that no table locks are held during index
|
||||
-- creation — zero downtime for existing installations.
|
||||
--
|
||||
-- NOTE: CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
|
||||
-- If using sqlx migrate, run this file manually:
|
||||
-- psql -f db/migrations/004_add_trigram_indexes.sql
|
||||
|
||||
-- 0. Enable the pg_trgm extension (idempotent)
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- 1. Contacts — search_contacts(), get_contacts_by_email()
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_full_name_trgm
|
||||
ON carddav.contacts USING gin (full_name gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_first_name_trgm
|
||||
ON carddav.contacts USING gin (first_name gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_last_name_trgm
|
||||
ON carddav.contacts USING gin (last_name gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_nickname_trgm
|
||||
ON carddav.contacts USING gin (nickname gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_organization_trgm
|
||||
ON carddav.contacts USING gin (organization gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_email_text_trgm
|
||||
ON carddav.contacts USING gin ((email::text) gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_phone_text_trgm
|
||||
ON carddav.contacts USING gin ((phone::text) gin_trgm_ops);
|
||||
|
||||
-- 2. Calendar events — find_events_by_summary()
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_calendar_events_summary_trgm
|
||||
ON caldav.calendar_events USING gin (summary gin_trgm_ops);
|
||||
|
||||
-- 3. Files — search_files_paginated(), search_files_in_subtree(), suggest_files_by_name()
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_name_trgm
|
||||
ON storage.files USING gin (name gin_trgm_ops);
|
||||
|
||||
-- 4. Folders — search_folders(), list_descendant_folders(), suggest_folders_by_name()
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_folders_name_trgm
|
||||
ON storage.folders USING gin (name gin_trgm_ops);
|
||||
@@ -143,6 +143,75 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON auth.users(oidc_provider, oi
|
||||
-- NOTE: No default users are created. The first user to register through
|
||||
-- the admin setup wizard will become the administrator.
|
||||
|
||||
-- Device Authorization Grant (RFC 8628)
|
||||
-- Used for WebDAV/CalDAV/CardDAV client authentication via the device flow.
|
||||
DO $BODY$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'device_code_status' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
CREATE TYPE auth.device_code_status AS ENUM (
|
||||
'pending', -- Waiting for user to authorize
|
||||
'authorized', -- User approved, tokens ready for polling client
|
||||
'denied', -- User denied the request
|
||||
'expired' -- TTL exceeded without user action
|
||||
);
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.device_codes (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
device_code VARCHAR(128) UNIQUE NOT NULL,
|
||||
user_code VARCHAR(16) UNIQUE NOT NULL,
|
||||
client_name VARCHAR(255) NOT NULL DEFAULT 'Unknown Client',
|
||||
scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav',
|
||||
status auth.device_code_status NOT NULL DEFAULT 'pending',
|
||||
user_id VARCHAR(36) REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
verification_uri TEXT NOT NULL,
|
||||
verification_uri_complete TEXT,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
poll_interval_secs INTEGER NOT NULL DEFAULT 5,
|
||||
last_poll_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
authorized_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_device_code
|
||||
ON auth.device_codes(device_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_user_code
|
||||
ON auth.device_codes(user_code) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_expires_at
|
||||
ON auth.device_codes(expires_at) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_device_codes_user_id
|
||||
ON auth.device_codes(user_id) WHERE status = 'authorized';
|
||||
|
||||
COMMENT ON TABLE auth.device_codes IS 'OAuth 2.0 Device Authorization Grant (RFC 8628) codes for DAV client authentication';
|
||||
|
||||
-- App Passwords (application-specific passwords for DAV clients with HTTP Basic Auth)
|
||||
CREATE TABLE IF NOT EXISTS auth.app_passwords (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
prefix VARCHAR(50) NOT NULL,
|
||||
scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
|
||||
ON auth.app_passwords(user_id) WHERE active = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_app_passwords_active
|
||||
ON auth.app_passwords(user_id, active) WHERE active = TRUE;
|
||||
|
||||
COMMENT ON TABLE auth.app_passwords IS 'Application-specific passwords for DAV clients using HTTP Basic Auth';
|
||||
|
||||
-- ============================================================
|
||||
-- 2. CALDAV SCHEMA (RFC 4791)
|
||||
-- ============================================================
|
||||
@@ -184,6 +253,9 @@ CREATE TABLE IF NOT EXISTS caldav.calendar_events (
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_calendar_id ON caldav.calendar_events(calendar_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_ical_uid ON caldav.calendar_events(ical_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_time_range ON caldav.calendar_events(calendar_id, start_time, end_time);
|
||||
-- GIN trigram index for ILIKE substring search (find_events_by_summary)
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_summary_trgm
|
||||
ON caldav.calendar_events USING gin (summary gin_trgm_ops);
|
||||
|
||||
-- Calendar sharing
|
||||
CREATE TABLE IF NOT EXISTS caldav.calendar_shares (
|
||||
@@ -214,6 +286,10 @@ COMMENT ON TABLE caldav.calendar_events IS 'Calendar events (VEVENT) stored with
|
||||
COMMENT ON TABLE caldav.calendar_shares IS 'Calendar sharing permissions between users';
|
||||
COMMENT ON TABLE caldav.calendar_properties IS 'Custom WebDAV properties on calendars';
|
||||
|
||||
-- ── pg_trgm extension for GIN trigram indexes (ILIKE / LIKE substring search) ──
|
||||
-- Required before creating any gin_trgm_ops indexes below.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. CARDDAV SCHEMA (RFC 6352)
|
||||
-- ============================================================
|
||||
@@ -262,6 +338,22 @@ CREATE INDEX IF NOT EXISTS idx_contacts_address_book_id ON carddav.contacts(addr
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_uid ON carddav.contacts(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_full_name ON carddav.contacts(full_name);
|
||||
|
||||
-- GIN trigram indexes for ILIKE substring search (search_contacts, get_contacts_by_email)
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_full_name_trgm
|
||||
ON carddav.contacts USING gin (full_name gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_first_name_trgm
|
||||
ON carddav.contacts USING gin (first_name gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_last_name_trgm
|
||||
ON carddav.contacts USING gin (last_name gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_nickname_trgm
|
||||
ON carddav.contacts USING gin (nickname gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_organization_trgm
|
||||
ON carddav.contacts USING gin (organization gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_email_text_trgm
|
||||
ON carddav.contacts USING gin ((email::text) gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_phone_text_trgm
|
||||
ON carddav.contacts USING gin ((phone::text) gin_trgm_ops);
|
||||
|
||||
-- Address book sharing
|
||||
CREATE TABLE IF NOT EXISTS carddav.address_book_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -372,6 +464,9 @@ CREATE INDEX IF NOT EXISTS idx_folders_trashed ON storage.folders(user_id, is_tr
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_lpath ON storage.folders USING gist (lpath);
|
||||
-- B-tree index on path for exact path lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders (path text_pattern_ops);
|
||||
-- GIN trigram index for ILIKE substring search (search_folders, suggest_folders_by_name)
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_name_trgm
|
||||
ON storage.folders USING gin (name gin_trgm_ops);
|
||||
|
||||
-- ── ltree trigger: compute path & lpath on INSERT or UPDATE of name/parent_id ──
|
||||
CREATE OR REPLACE FUNCTION storage.compute_folder_path()
|
||||
@@ -459,6 +554,9 @@ CREATE INDEX IF NOT EXISTS idx_files_folder_id ON storage.files(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_blob_hash ON storage.files(blob_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_trashed ON storage.files(user_id, is_trashed);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_name_search ON storage.files(user_id, name text_pattern_ops);
|
||||
-- GIN trigram index for ILIKE substring search (search_files, suggest_files_by_name)
|
||||
CREATE INDEX IF NOT EXISTS idx_files_name_trgm
|
||||
ON storage.files USING gin (name gin_trgm_ops);
|
||||
|
||||
-- Trash view combining trashed files and folders for the TrashRepository.
|
||||
-- Only shows top-level trashed items: excludes files/folders whose parent
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! DTOs for App Password (application-specific passwords for DAV clients).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// Request DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// POST /api/auth/app-passwords — create a new app password
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAppPasswordRequestDto {
|
||||
/// Human-readable label (e.g. "DAVx5 on Pixel 8")
|
||||
pub label: String,
|
||||
/// Comma-separated scopes (defaults to all DAV protocols)
|
||||
#[serde(default = "default_scopes")]
|
||||
pub scopes: String,
|
||||
/// Optional expiration in days (None = never expires)
|
||||
pub expires_in_days: Option<u32>,
|
||||
}
|
||||
|
||||
fn default_scopes() -> String {
|
||||
"webdav,caldav,carddav".to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Response when an app password is created — includes the plain-text password
|
||||
/// that is shown ONCE to the user.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppPasswordCreatedResponseDto {
|
||||
/// Unique identifier for this app password.
|
||||
pub id: String,
|
||||
/// The label chosen by the user.
|
||||
pub label: String,
|
||||
/// The plain-text app password — shown only ONCE.
|
||||
/// Format: `oxicloud-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
|
||||
pub password: String,
|
||||
/// The username to use with HTTP Basic Auth.
|
||||
pub username: String,
|
||||
/// Active scopes.
|
||||
pub scopes: String,
|
||||
/// Expiration date or null for never.
|
||||
pub expires_at: Option<String>,
|
||||
/// Usage instructions for common clients.
|
||||
pub instructions: AppPasswordInstructions,
|
||||
}
|
||||
|
||||
/// Usage instructions included in the creation response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppPasswordInstructions {
|
||||
pub davx5: String,
|
||||
pub thunderbird: String,
|
||||
pub rclone: String,
|
||||
pub curl_example: String,
|
||||
}
|
||||
|
||||
/// Summary of an app password (list view — never includes the plain-text password).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppPasswordSummaryDto {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// First 8 chars of the token for identification.
|
||||
pub prefix: String,
|
||||
pub scopes: String,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Response for list endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppPasswordListResponseDto {
|
||||
pub app_passwords: Vec<AppPasswordSummaryDto>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Response for revoke endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppPasswordRevokeResponseDto {
|
||||
pub status: String,
|
||||
pub id: String,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! DTOs for OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// Request DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// POST /api/auth/device/authorize — request body
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceAuthorizeRequestDto {
|
||||
/// Human-readable name of the client (e.g. "rclone", "DAVx⁵")
|
||||
#[serde(default = "default_client_name")]
|
||||
pub client_name: String,
|
||||
/// Comma-separated scopes (e.g. "webdav,caldav,carddav")
|
||||
#[serde(default = "default_scopes")]
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
fn default_client_name() -> String {
|
||||
"Unknown Client".to_string()
|
||||
}
|
||||
|
||||
fn default_scopes() -> String {
|
||||
"webdav,caldav,carddav".to_string()
|
||||
}
|
||||
|
||||
/// POST /api/auth/device/verify — user submits the code from the browser
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceVerifyRequestDto {
|
||||
/// The user_code displayed on the client device
|
||||
pub user_code: String,
|
||||
/// Whether the user approves ("approve") or denies ("deny")
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/device/token — client polls for tokens
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceTokenRequestDto {
|
||||
/// The device_code received from the initial authorize call
|
||||
pub device_code: String,
|
||||
/// Must be "urn:ietf:params:oauth:grant-type:device_code"
|
||||
#[serde(default)]
|
||||
pub grant_type: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Response to POST /api/auth/device/authorize (RFC 8628 §3.2)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceAuthorizeResponseDto {
|
||||
/// The device verification code
|
||||
pub device_code: String,
|
||||
/// The end-user verification code (short, human-readable)
|
||||
pub user_code: String,
|
||||
/// The end-user verification URI
|
||||
pub verification_uri: String,
|
||||
/// Optional: verification URI with user_code pre-filled
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verification_uri_complete: Option<String>,
|
||||
/// Lifetime in seconds of the device_code and user_code
|
||||
pub expires_in: i64,
|
||||
/// Minimum polling interval in seconds
|
||||
pub interval: i32,
|
||||
}
|
||||
|
||||
/// Response to POST /api/auth/device/token when authorization is still pending
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceTokenPendingDto {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
/// Response to POST /api/auth/device/token when authorization is complete
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceTokenSuccessDto {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in: i64,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
/// GET /api/auth/device/verify — info about the pending device code
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceVerifyInfoDto {
|
||||
/// The client name requesting access
|
||||
pub client_name: String,
|
||||
/// Scopes being requested
|
||||
pub scopes: String,
|
||||
/// Whether the user_code is valid and pending
|
||||
pub valid: bool,
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod address_book_dto;
|
||||
pub mod app_password_dto;
|
||||
pub mod calendar_dto;
|
||||
pub mod contact_dto;
|
||||
pub mod device_auth_dto;
|
||||
pub mod display_helpers;
|
||||
pub mod favorites_dto;
|
||||
pub mod file_dto;
|
||||
|
||||
@@ -2,6 +2,5 @@ pub mod adapters;
|
||||
pub mod dtos;
|
||||
pub mod ports;
|
||||
pub mod services;
|
||||
pub mod transactions;
|
||||
|
||||
// Re-exportaciones para facilitar el acceso a los principales puertos
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use crate::domain::entities::device_code::DeviceCode;
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::domain::entities::user::User;
|
||||
use async_trait::async_trait;
|
||||
@@ -202,3 +204,61 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// Revokes all sessions of a user
|
||||
async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Authorization Grant Port (RFC 8628)
|
||||
// ============================================================================
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeviceCodeStoragePort: Send + Sync + 'static {
|
||||
/// Persist a new device code flow
|
||||
async fn create_device_code(&self, device_code: DeviceCode) -> Result<DeviceCode, DomainError>;
|
||||
|
||||
/// Find a device code by its opaque device_code token (used by client polling)
|
||||
async fn get_by_device_code(&self, device_code: &str) -> Result<DeviceCode, DomainError>;
|
||||
|
||||
/// Find a pending device code by the short user_code (used on verification page)
|
||||
async fn get_pending_by_user_code(&self, user_code: &str) -> Result<DeviceCode, DomainError>;
|
||||
|
||||
/// Update a device code (status change, token storage, poll timestamp, etc.)
|
||||
async fn update_device_code(&self, device_code: DeviceCode) -> Result<(), DomainError>;
|
||||
|
||||
/// Delete expired device codes (cleanup job)
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
|
||||
/// List authorized device codes for a user (for UI management)
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<DeviceCode>, DomainError>;
|
||||
|
||||
/// Delete a specific device code by ID (revocation)
|
||||
async fn delete_by_id(&self, id: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Password Storage Port
|
||||
// ============================================================================
|
||||
|
||||
/// Storage port for application-specific passwords (HTTP Basic Auth for DAV clients).
|
||||
#[async_trait]
|
||||
pub trait AppPasswordStoragePort: Send + Sync + 'static {
|
||||
/// Persist a new app password (hash already computed).
|
||||
async fn create(&self, app_password: AppPassword) -> Result<AppPassword, DomainError>;
|
||||
|
||||
/// Get all active (non-expired) app passwords for a user.
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError>;
|
||||
|
||||
/// Get a specific app password by ID.
|
||||
async fn get_by_id(&self, id: &str) -> Result<AppPassword, DomainError>;
|
||||
|
||||
/// Get all active app passwords for a user ID (for Basic auth verification).
|
||||
/// This includes the password hash for verification.
|
||||
async fn get_active_by_user_id(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError>;
|
||||
|
||||
/// Update the `last_used_at` timestamp after a successful authentication.
|
||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Deactivate (soft-delete) an app password.
|
||||
async fn revoke(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Hard-delete expired/revoked app passwords (cleanup).
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ pub trait ChunkedUploadPort: Send + Sync + 'static {
|
||||
|
||||
/// Assemble all chunks into the final file.
|
||||
///
|
||||
/// Returns `(assembled_file_path, filename, folder_id, content_type, total_size, sha256_hash)`.
|
||||
/// Returns `(assembled_file_path, filename, folder_id, content_type, total_size, blake3_hash)`.
|
||||
/// The hash is computed during assembly (hash-on-write), eliminating a
|
||||
/// second sequential read of the assembled file.
|
||||
async fn complete_upload(
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::pin::Pin;
|
||||
/// Metadata of a stored blob in the dedup system.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BlobMetadataDto {
|
||||
/// SHA-256 hash of the content.
|
||||
/// BLAKE3 hash of the content.
|
||||
pub hash: String,
|
||||
/// Size in bytes.
|
||||
pub size: u64,
|
||||
@@ -151,7 +151,7 @@ pub trait DedupPort: Send + Sync + 'static {
|
||||
/// Returns `true` if the blob was deleted (ref_count reached 0).
|
||||
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
|
||||
|
||||
/// Calculate SHA-256 hash of a file (streaming).
|
||||
/// Calculate BLAKE3 hash of a file (streaming).
|
||||
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
|
||||
|
||||
/// Get deduplication statistics.
|
||||
|
||||
@@ -55,7 +55,7 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
|
||||
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
|
||||
///
|
||||
/// Returns the SHA-256 hash stored in `storage.files.blob_hash`.
|
||||
/// Returns the BLAKE3 hash stored in `storage.files.blob_hash`.
|
||||
/// Used for dedup reference tracking without loading file content.
|
||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError>;
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! App Password application service.
|
||||
//!
|
||||
//! Orchestrates creation, verification, listing, and revocation of
|
||||
//! application-specific passwords for DAV clients.
|
||||
|
||||
use crate::application::dtos::app_password_dto::*;
|
||||
use crate::application::ports::auth_ports::{
|
||||
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// App password token length (32 random alphanumeric chars after prefix).
|
||||
const TOKEN_LENGTH: usize = 32;
|
||||
/// Prefix for all app password tokens (makes them easily identifiable).
|
||||
const TOKEN_PREFIX: &str = "oxicloud-";
|
||||
|
||||
pub struct AppPasswordService {
|
||||
repo: Arc<dyn AppPasswordStoragePort>,
|
||||
hasher: Arc<dyn PasswordHasherPort>,
|
||||
user_repo: Arc<dyn UserStoragePort>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl AppPasswordService {
|
||||
pub fn new(
|
||||
repo: Arc<dyn AppPasswordStoragePort>,
|
||||
hasher: Arc<dyn PasswordHasherPort>,
|
||||
user_repo: Arc<dyn UserStoragePort>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
hasher,
|
||||
user_repo,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random app password token using cryptographic RNG.
|
||||
fn generate_token() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
let charset: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ\
|
||||
0123456789";
|
||||
let mut rng_bytes = [0u8; TOKEN_LENGTH];
|
||||
OsRng.fill_bytes(&mut rng_bytes);
|
||||
|
||||
let random_part: String = rng_bytes
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
let idx = (b as usize) % charset.len();
|
||||
charset[idx] as char
|
||||
})
|
||||
.collect();
|
||||
format!("{}{}", TOKEN_PREFIX, random_part)
|
||||
}
|
||||
|
||||
/// Create a new app password for the given user.
|
||||
///
|
||||
/// Returns the response DTO that includes the plain-text password (shown only once).
|
||||
pub async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
request: CreateAppPasswordRequestDto,
|
||||
) -> Result<AppPasswordCreatedResponseDto, DomainError> {
|
||||
// Validate label
|
||||
let label = request.label.trim().to_string();
|
||||
if label.is_empty() || label.len() > 255 {
|
||||
return Err(DomainError::validation_error(
|
||||
"Label must be 1-255 characters",
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user for the username (needed for Basic Auth instructions)
|
||||
let user = self.user_repo.get_user_by_id(user_id).await?;
|
||||
let username = user.username().to_string();
|
||||
|
||||
// Generate the plain-text token
|
||||
let plain_token = Self::generate_token();
|
||||
let prefix = plain_token[..TOKEN_PREFIX.len() + 8].to_string();
|
||||
|
||||
// Hash the token for storage
|
||||
let password_hash = self.hasher.hash_password(&plain_token).await?;
|
||||
|
||||
// Calculate expiration
|
||||
let expires_at = request.expires_in_days.map(|days| {
|
||||
Utc::now() + Duration::days(days as i64)
|
||||
});
|
||||
|
||||
// Create entity
|
||||
let app_password = AppPassword::new(
|
||||
user_id.to_string(),
|
||||
label.clone(),
|
||||
password_hash,
|
||||
prefix.clone(),
|
||||
request.scopes.clone(),
|
||||
expires_at,
|
||||
);
|
||||
|
||||
let saved = self.repo.create(app_password).await?;
|
||||
|
||||
let expires_str = saved
|
||||
.expires_at
|
||||
.map(|dt| dt.to_rfc3339());
|
||||
|
||||
let curl_example = format!(
|
||||
"curl -u '{}:{}' -X PROPFIND {}/webdav/",
|
||||
username, plain_token, self.base_url
|
||||
);
|
||||
|
||||
Ok(AppPasswordCreatedResponseDto {
|
||||
id: saved.id,
|
||||
label,
|
||||
password: plain_token,
|
||||
username: username.clone(),
|
||||
scopes: request.scopes,
|
||||
expires_at: expires_str,
|
||||
instructions: AppPasswordInstructions {
|
||||
davx5: format!(
|
||||
"In DAVx⁵, add account with base URL: {}/webdav/\n\
|
||||
Username: {}\n\
|
||||
Password: (the token shown above)",
|
||||
self.base_url, username
|
||||
),
|
||||
thunderbird: format!(
|
||||
"In Thunderbird CalDAV/CardDAV:\n\
|
||||
URL: {}/caldav/ or {}/carddav/\n\
|
||||
Username: {}\n\
|
||||
Password: (the token shown above)",
|
||||
self.base_url, self.base_url, username
|
||||
),
|
||||
rclone: format!(
|
||||
"rclone config:\n\
|
||||
type = webdav\n\
|
||||
url = {}/webdav/\n\
|
||||
vendor = other\n\
|
||||
user = {}\n\
|
||||
pass = (the token shown above, use 'rclone obscure' to encode)",
|
||||
self.base_url, username
|
||||
),
|
||||
curl_example,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// List all app passwords for a user (excludes plain-text passwords).
|
||||
pub async fn list(&self, user_id: &str) -> Result<AppPasswordListResponseDto, DomainError> {
|
||||
let passwords = self.repo.list_by_user(user_id).await?;
|
||||
let total = passwords.len();
|
||||
|
||||
let app_passwords = passwords
|
||||
.into_iter()
|
||||
.map(|ap| {
|
||||
let is_active = ap.active && !ap.is_expired();
|
||||
AppPasswordSummaryDto {
|
||||
id: ap.id,
|
||||
label: ap.label,
|
||||
prefix: format!("{}...", ap.prefix),
|
||||
scopes: ap.scopes,
|
||||
created_at: ap.created_at.to_rfc3339(),
|
||||
last_used_at: ap.last_used_at.map(|dt| dt.to_rfc3339()),
|
||||
expires_at: ap.expires_at.map(|dt| dt.to_rfc3339()),
|
||||
active: is_active,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(AppPasswordListResponseDto {
|
||||
app_passwords,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Revoke (soft-delete) an app password. Verifies ownership.
|
||||
pub async fn revoke(&self, user_id: &str, id: &str) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
||||
let ap = self.repo.get_by_id(id).await?;
|
||||
if ap.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"You can only revoke your own app passwords",
|
||||
));
|
||||
}
|
||||
self.repo.revoke(id).await?;
|
||||
Ok(AppPasswordRevokeResponseDto {
|
||||
status: "revoked".to_string(),
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify username + app password for HTTP Basic Auth.
|
||||
///
|
||||
/// Returns `(user_id, username, email, role)` on success.
|
||||
pub async fn verify_basic_auth(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(String, String, String, String), DomainError> {
|
||||
// Look up user by username
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
.await
|
||||
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
||||
|
||||
// Get all active app passwords for this user
|
||||
let app_passwords = self
|
||||
.repo
|
||||
.get_active_by_user_id(user.id())
|
||||
.await?;
|
||||
|
||||
if app_passwords.is_empty() {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
));
|
||||
}
|
||||
|
||||
// Try each app password hash
|
||||
for ap in &app_passwords {
|
||||
if let Ok(true) = self.hasher.verify_password(password, &ap.password_hash).await {
|
||||
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
|
||||
let _ = self.repo.touch_last_used(&ap.id).await;
|
||||
|
||||
return Ok((
|
||||
user.id().to_string(),
|
||||
user.username().to_string(),
|
||||
user.email().to_string(),
|
||||
user.role().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
use async_zip::base::write::ZipFileWriter;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use futures::io::AsyncWriteExt as FuturesWriteExt;
|
||||
use futures::{Future, StreamExt, future::join_all};
|
||||
use futures::{Future, StreamExt, stream};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::NamedTempFile;
|
||||
use thiserror::Error;
|
||||
use tokio::io::BufWriter;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::info;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
@@ -71,7 +70,6 @@ pub struct BatchOperationService {
|
||||
folder_service: Arc<FolderService>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
config: AppConfig,
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl BatchOperationService {
|
||||
@@ -82,16 +80,12 @@ impl BatchOperationService {
|
||||
folder_service: Arc<FolderService>,
|
||||
config: AppConfig,
|
||||
) -> Self {
|
||||
// Limit concurrency based on configuration
|
||||
let max_concurrency = config.concurrency.max_concurrent_files;
|
||||
|
||||
Self {
|
||||
file_retrieval,
|
||||
file_management,
|
||||
folder_service,
|
||||
trash_service: None,
|
||||
config,
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrency)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +117,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||
info!("Starting batch copy of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
let max_concurrent = self.config.concurrency.max_concurrent_files;
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
@@ -134,31 +129,23 @@ impl BatchOperationService {
|
||||
},
|
||||
};
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
// Arc<str> avoids N heap-clones of the same string
|
||||
let target_folder: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||
|
||||
// buffer_unordered materialises only max_concurrent futures at a time
|
||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||
let mgmt = self.file_management.clone();
|
||||
let target_folder = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let target_folder = target_folder.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let copy_result = mgmt.copy_file(&file_id, target_folder.clone()).await;
|
||||
|
||||
// Release the permit explicitly (also released on drop)
|
||||
drop(permit);
|
||||
|
||||
// Return the result along with the ID to identify successes/failures
|
||||
let copy_result = mgmt.copy_file(&file_id, target_folder.map(|s| s.to_string())).await;
|
||||
(file_id, copy_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(max_concurrent);
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
// Process results as they complete
|
||||
while let Some((file_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(file) => {
|
||||
result.successful.push(file);
|
||||
@@ -195,6 +182,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||
info!("Starting batch move of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
let max_concurrent = self.config.concurrency.max_concurrent_files;
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
@@ -206,31 +194,20 @@ impl BatchOperationService {
|
||||
},
|
||||
};
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let target_folder: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||
|
||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||
let mgmt = self.file_management.clone();
|
||||
let target_folder = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let target_folder = target_folder.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let move_result = mgmt.move_file(&file_id, target_folder.clone()).await;
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
// Return the result along with the ID to identify successes/failures
|
||||
let move_result = mgmt.move_file(&file_id, target_folder.map(|s| s.to_string())).await;
|
||||
(file_id, move_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(max_concurrent);
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
while let Some((file_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(file) => {
|
||||
result.successful.push(file);
|
||||
@@ -278,30 +255,19 @@ impl BatchOperationService {
|
||||
};
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||
let mgmt = self.file_management.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let id_clone = file_id.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let delete_result = mgmt.delete_file(&file_id).await;
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
// Return the result along with the ID
|
||||
(id_clone.clone(), delete_result.map(|_| id_clone))
|
||||
let id_for_result = file_id.clone();
|
||||
(file_id, delete_result.map(|_| id_for_result))
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
// Process results as they complete
|
||||
while let Some((file_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
@@ -349,29 +315,18 @@ impl BatchOperationService {
|
||||
};
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||
let retrieval = self.file_retrieval.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let get_result = retrieval.get_file(&file_id).await;
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
// Return the result along with the ID
|
||||
(file_id, get_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
// Process results as they complete
|
||||
while let Some((file_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(file) => {
|
||||
result.successful.push(file);
|
||||
@@ -421,31 +376,22 @@ impl BatchOperationService {
|
||||
};
|
||||
|
||||
// Define the operation to perform for each folder
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
// Arc<str> avoids N heap-clones of the caller string
|
||||
let caller: Arc<str> = Arc::from(caller_id);
|
||||
|
||||
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let id_clone = folder_id.clone();
|
||||
let caller = caller_id.to_string();
|
||||
let caller = caller.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let delete_result = folder_service.delete_folder(&folder_id, &caller).await;
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
// Return the result along with the ID
|
||||
(id_clone.clone(), delete_result.map(|_| id_clone))
|
||||
let id_for_result = folder_id.clone();
|
||||
(folder_id, delete_result.map(|_| id_for_result))
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
while let Some((folder_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
@@ -497,23 +443,21 @@ impl BatchOperationService {
|
||||
},
|
||||
};
|
||||
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let uid: Arc<str> = Arc::from(user_id);
|
||||
|
||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||
let trash = trash_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let uid = user_id.to_string();
|
||||
let id_clone = file_id.clone();
|
||||
let uid = uid.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let trash_result = trash.move_to_trash(&file_id, "file", &uid).await;
|
||||
drop(permit);
|
||||
(id_clone.clone(), trash_result.map(|_| id_clone))
|
||||
let id_for_result = file_id.clone();
|
||||
(file_id, trash_result.map(|_| id_for_result))
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (file_id, operation_result) in operation_results {
|
||||
while let Some((file_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
@@ -564,23 +508,21 @@ impl BatchOperationService {
|
||||
},
|
||||
};
|
||||
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let uid: Arc<str> = Arc::from(user_id);
|
||||
|
||||
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||
let trash = trash_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let uid = user_id.to_string();
|
||||
let id_clone = folder_id.clone();
|
||||
let uid = uid.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let trash_result = trash.move_to_trash(&folder_id, "folder", &uid).await;
|
||||
drop(permit);
|
||||
(id_clone.clone(), trash_result.map(|_| id_clone))
|
||||
let id_for_result = folder_id.clone();
|
||||
(folder_id, trash_result.map(|_| id_for_result))
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
while let Some((folder_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
@@ -627,24 +569,23 @@ impl BatchOperationService {
|
||||
},
|
||||
};
|
||||
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let target: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||
let caller: Arc<str> = Arc::from(caller_id);
|
||||
|
||||
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let target = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let caller = caller_id.to_string();
|
||||
let target = target.clone();
|
||||
let caller = caller.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let dto = MoveFolderDto { parent_id: target };
|
||||
let dto = MoveFolderDto { parent_id: target.map(|s| s.to_string()) };
|
||||
let move_result = folder_service.move_folder(&folder_id, dto, &caller).await;
|
||||
drop(permit);
|
||||
(folder_id, move_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
while let Some((folder_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(folder) => {
|
||||
result.successful.push(folder);
|
||||
@@ -873,7 +814,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<T>, BatchOperationError>
|
||||
where
|
||||
T: Clone + Send + 'static + std::fmt::Debug,
|
||||
F: Fn(T, Arc<Semaphore>) -> Fut + Clone + Send + Sync + 'static,
|
||||
F: Fn(T) -> Fut + Clone + Send + Sync + 'static,
|
||||
Fut: Future<Output = Result<T, DomainError>> + Send + 'static,
|
||||
{
|
||||
info!(
|
||||
@@ -892,33 +833,25 @@ impl BatchOperationService {
|
||||
},
|
||||
};
|
||||
|
||||
// Convert each item to a task
|
||||
let tasks = items.iter().map(|item| {
|
||||
let item_clone = item.clone();
|
||||
// buffer_unordered materialises only max_concurrent futures at a time
|
||||
let mut operation_stream = stream::iter(items.into_iter().map(|item| {
|
||||
let op = operation.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
async move {
|
||||
// The provided function must handle semaphore acquisition
|
||||
let op_result = op(item_clone.clone(), semaphore).await;
|
||||
|
||||
// Return the result along with the original item for identification
|
||||
(item_clone, op_result)
|
||||
let op_result = op(item.clone()).await;
|
||||
(item, op_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
// Execute all tasks in parallel
|
||||
let operation_results = join_all(tasks).await;
|
||||
|
||||
// Process results
|
||||
for (item, operation_result) in operation_results {
|
||||
// Process results as they complete
|
||||
while let Some((item, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(result_item) => {
|
||||
result.successful.push(result_item);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
// Convert item to string for error reporting
|
||||
result.failed.push((format!("{:?}", item), e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
@@ -960,34 +893,23 @@ impl BatchOperationService {
|
||||
};
|
||||
|
||||
// Define the operation for each folder
|
||||
let operations = folders.into_iter().map(|(name, parent_id)| {
|
||||
let mut operation_stream = stream::iter(folders.into_iter().map(|(name, parent_id)| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||
name: name.clone(),
|
||||
parent_id: parent_id.clone(),
|
||||
};
|
||||
let create_result = folder_service.create_folder(dto).await;
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
// Return the result with an identifier for errors
|
||||
let id = format!("{}:{}", name, parent_id.unwrap_or_default());
|
||||
(id, create_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
// Execute all operations in parallel
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (id, operation_result) in operation_results {
|
||||
// Process results as they complete
|
||||
while let Some((id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(folder) => {
|
||||
result.successful.push(folder);
|
||||
@@ -1035,29 +957,18 @@ impl BatchOperationService {
|
||||
};
|
||||
|
||||
// Define the operation for each folder
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let get_result = folder_service.get_folder(&folder_id).await;
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
// Return the result with its ID
|
||||
(folder_id, get_result)
|
||||
}
|
||||
});
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
// Execute all operations in parallel
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
// Process the results
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
// Process results as they complete
|
||||
while let Some((folder_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(folder) => {
|
||||
result.successful.push(folder);
|
||||
@@ -1105,11 +1016,8 @@ mod tests {
|
||||
AppConfig::default(),
|
||||
);
|
||||
|
||||
// Define a generic test operation
|
||||
let operation = |item: i32, semaphore: Arc<Semaphore>| async move {
|
||||
// Acquire and release the semaphore
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
// Define a generic test operation (no more semaphore parameter)
|
||||
let operation = |item: i32| async move {
|
||||
if item % 2 == 0 {
|
||||
// Simulate success for even numbers
|
||||
Ok(item * 2)
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! OAuth 2.0 Device Authorization Grant service (RFC 8628).
|
||||
//!
|
||||
//! Orchestrates the full device flow:
|
||||
//! 1. `initiate` — generates device_code + user_code, stores in DB
|
||||
//! 2. `verify_user_code` — looks up pending code for the verification page
|
||||
//! 3. `approve` — user approves, tokens are generated and stored
|
||||
//! 4. `deny` — user denies the request
|
||||
//! 5. `poll` — client polls by device_code; returns tokens or status error
|
||||
//! 6. `cleanup_expired` — background job to purge stale entries
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::ports::auth_ports::{DeviceCodeStoragePort, TokenServicePort, UserStoragePort};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::application::ports::auth_ports::SessionStoragePort;
|
||||
|
||||
/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min).
|
||||
const DEVICE_CODE_LIFETIME_SECS: i64 = 900;
|
||||
|
||||
/// Default polling interval in seconds (RFC 8628 §3.2 recommends 5s).
|
||||
const DEFAULT_POLL_INTERVAL: i32 = 5;
|
||||
|
||||
/// Length of the device_code (hex-encoded, 64 chars = 32 bytes).
|
||||
const DEVICE_CODE_BYTES: usize = 32;
|
||||
|
||||
/// User code format: 4 uppercase letters + hyphen + 4 digits → "ABCD-1234"
|
||||
/// Short enough to type, long enough to avoid collisions with 26^4 * 10^4 = ~4.5 billion combos.
|
||||
const USER_CODE_LETTER_LEN: usize = 4;
|
||||
const USER_CODE_DIGIT_LEN: usize = 4;
|
||||
|
||||
pub struct DeviceAuthService {
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
/// Base URL of the server (e.g. "https://cloud.example.com")
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl DeviceAuthService {
|
||||
pub fn new(
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_code_storage,
|
||||
token_service,
|
||||
user_storage,
|
||||
session_storage,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 1. Initiate — called by the DAV client
|
||||
// ========================================================================
|
||||
|
||||
/// Start a new device authorization flow.
|
||||
///
|
||||
/// Returns the response that the client displays to the user.
|
||||
pub async fn initiate(
|
||||
&self,
|
||||
req: DeviceAuthorizeRequestDto,
|
||||
) -> Result<DeviceAuthorizeResponseDto, DomainError> {
|
||||
let device_code_token = generate_device_code();
|
||||
let user_code = generate_user_code();
|
||||
|
||||
let verification_uri = format!("{}/device", self.base_url.trim_end_matches('/'));
|
||||
let verification_uri_complete = format!("{}?code={}", verification_uri, user_code);
|
||||
|
||||
let dc = DeviceCode::new(
|
||||
device_code_token.clone(),
|
||||
user_code.clone(),
|
||||
req.client_name,
|
||||
req.scope,
|
||||
verification_uri.clone(),
|
||||
Some(verification_uri_complete.clone()),
|
||||
DEVICE_CODE_LIFETIME_SECS,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
);
|
||||
|
||||
let dc = self.device_code_storage.create_device_code(dc).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device auth flow initiated: user_code={}, expires_in={}s",
|
||||
user_code,
|
||||
DEVICE_CODE_LIFETIME_SECS
|
||||
);
|
||||
|
||||
Ok(DeviceAuthorizeResponseDto {
|
||||
device_code: device_code_token,
|
||||
user_code,
|
||||
verification_uri,
|
||||
verification_uri_complete: Some(verification_uri_complete),
|
||||
expires_in: dc.seconds_remaining(),
|
||||
interval: DEFAULT_POLL_INTERVAL,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. Verify — user opens the verification page, looks up pending code
|
||||
// ========================================================================
|
||||
|
||||
/// Look up a pending device code by user_code for the verification page.
|
||||
pub async fn verify_user_code(
|
||||
&self,
|
||||
user_code: &str,
|
||||
) -> Result<DeviceVerifyInfoDto, DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
match self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await
|
||||
{
|
||||
Ok(dc) => {
|
||||
if dc.is_expired() {
|
||||
return Ok(DeviceVerifyInfoDto {
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
valid: false,
|
||||
});
|
||||
}
|
||||
Ok(DeviceVerifyInfoDto {
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(DeviceVerifyInfoDto {
|
||||
client_name: String::new(),
|
||||
scopes: String::new(),
|
||||
valid: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. Approve — authenticated user approves the device code
|
||||
// ========================================================================
|
||||
|
||||
/// Approve a device code, generating tokens for the polling client.
|
||||
///
|
||||
/// * `user_code` — the code from the verification page
|
||||
/// * `user_id` — the authenticated user's ID (from session/JWT)
|
||||
pub async fn approve(
|
||||
&self,
|
||||
user_code: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await?;
|
||||
|
||||
if dc.is_expired() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"DeviceCode",
|
||||
"Device code has expired. Please start a new authorization flow.",
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user to generate tokens
|
||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
|
||||
// Generate internal JWT access token + refresh token
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// Persist refresh token as a session
|
||||
let session = Session::new(
|
||||
user_id.to_string(),
|
||||
refresh_token.clone(),
|
||||
None, // ip_address
|
||||
Some(format!("device:{}", dc.client_name())), // user_agent
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
);
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
// Store tokens on the device code entity
|
||||
dc.authorize(user_id.to_string(), access_token, refresh_token);
|
||||
self.device_code_storage.update_device_code(dc).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device code approved by user {} (user_code={})",
|
||||
user_id,
|
||||
normalized
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. Deny — authenticated user denies the device code
|
||||
// ========================================================================
|
||||
|
||||
pub async fn deny(&self, user_code: &str) -> Result<(), DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await?;
|
||||
|
||||
dc.deny();
|
||||
self.device_code_storage.update_device_code(dc).await?;
|
||||
|
||||
tracing::info!("Device code denied (user_code={})", normalized);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. Poll — client polls by device_code for tokens
|
||||
// ========================================================================
|
||||
|
||||
/// Client polls for tokens. Returns:
|
||||
/// - `Ok(DeviceTokenSuccessDto)` if authorized
|
||||
/// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied
|
||||
pub async fn poll(
|
||||
&self,
|
||||
device_code: &str,
|
||||
) -> Result<DeviceTokenSuccessDto, DevicePollError> {
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_by_device_code(device_code)
|
||||
.await
|
||||
.map_err(|_| DevicePollError::InvalidDeviceCode)?;
|
||||
|
||||
// Check expiry first
|
||||
if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending {
|
||||
let mut expired_dc = dc.clone();
|
||||
expired_dc.mark_expired();
|
||||
let _ = self.device_code_storage.update_device_code(expired_dc).await;
|
||||
return Err(DevicePollError::ExpiredToken);
|
||||
}
|
||||
|
||||
match dc.status() {
|
||||
DeviceCodeStatus::Pending => {
|
||||
// Check for slow_down (polling too fast)
|
||||
if dc.is_polling_too_fast() {
|
||||
return Err(DevicePollError::SlowDown);
|
||||
}
|
||||
// Record this poll
|
||||
dc.record_poll();
|
||||
let _ = self.device_code_storage.update_device_code(dc).await;
|
||||
Err(DevicePollError::AuthorizationPending)
|
||||
}
|
||||
DeviceCodeStatus::Authorized => {
|
||||
let access_token = dc.access_token().unwrap_or_default().to_string();
|
||||
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
|
||||
let scope = dc.scopes().to_string();
|
||||
|
||||
Ok(DeviceTokenSuccessDto {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
refresh_token,
|
||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||
scope,
|
||||
})
|
||||
}
|
||||
DeviceCodeStatus::Denied => Err(DevicePollError::AccessDenied),
|
||||
DeviceCodeStatus::Expired => Err(DevicePollError::ExpiredToken),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6. Cleanup — purge expired entries
|
||||
// ========================================================================
|
||||
|
||||
pub async fn cleanup_expired(&self) -> Result<u64, DomainError> {
|
||||
let deleted = self.device_code_storage.delete_expired().await?;
|
||||
if deleted > 0 {
|
||||
tracing::info!("Device code cleanup: {} expired entries removed", deleted);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 7. List — user's authorized devices (for UI)
|
||||
// ========================================================================
|
||||
|
||||
pub async fn list_user_devices(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DeviceInfoDto>, DomainError> {
|
||||
let codes = self.device_code_storage.list_by_user(user_id).await?;
|
||||
Ok(codes
|
||||
.into_iter()
|
||||
.map(|dc| DeviceInfoDto {
|
||||
id: dc.id().to_string(),
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
status: dc.status().as_str().to_string(),
|
||||
created_at: dc.created_at().to_rfc3339(),
|
||||
authorized_at: dc.authorized_at().map(|t| t.to_rfc3339()),
|
||||
expires_at: dc.expires_at().to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 8. Revoke — user revokes a device authorization
|
||||
// ========================================================================
|
||||
|
||||
pub async fn revoke_device(&self, device_id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
// Verify ownership before deleting
|
||||
let devices = self.device_code_storage.list_by_user(user_id).await?;
|
||||
let found = devices.iter().any(|d| d.id() == device_id);
|
||||
if !found {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"Device authorization not found or not owned by you",
|
||||
));
|
||||
}
|
||||
self.device_code_storage.delete_by_id(device_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Poll error (typed for RFC 8628 error responses)
|
||||
// ============================================================================
|
||||
|
||||
/// Typed errors for the device token polling endpoint (RFC 8628 §3.5).
|
||||
#[derive(Debug)]
|
||||
pub enum DevicePollError {
|
||||
/// The authorization request is still pending (user hasn't acted yet).
|
||||
AuthorizationPending,
|
||||
/// The client is polling too fast; increase the interval.
|
||||
SlowDown,
|
||||
/// The user denied the authorization request.
|
||||
AccessDenied,
|
||||
/// The device_code has expired.
|
||||
ExpiredToken,
|
||||
/// The device_code is not recognized.
|
||||
InvalidDeviceCode,
|
||||
}
|
||||
|
||||
impl DevicePollError {
|
||||
/// RFC 8628 error string for the JSON response.
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationPending => "authorization_pending",
|
||||
Self::SlowDown => "slow_down",
|
||||
Self::AccessDenied => "access_denied",
|
||||
Self::ExpiredToken => "expired_token",
|
||||
Self::InvalidDeviceCode => "invalid_grant",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationPending => {
|
||||
"The authorization request is still pending. Continue polling."
|
||||
}
|
||||
Self::SlowDown => "You are polling too frequently. Please slow down.",
|
||||
Self::AccessDenied => "The user denied the authorization request.",
|
||||
Self::ExpiredToken => {
|
||||
"The device_code has expired. Please start a new authorization flow."
|
||||
}
|
||||
Self::InvalidDeviceCode => "The device_code is not recognized.",
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status code per RFC 8628 §3.5:
|
||||
/// - authorization_pending and slow_down: 400
|
||||
/// - access_denied: 403
|
||||
/// - expired_token: 400
|
||||
pub fn http_status(&self) -> u16 {
|
||||
match self {
|
||||
Self::AuthorizationPending | Self::SlowDown | Self::ExpiredToken => 400,
|
||||
Self::AccessDenied => 403,
|
||||
Self::InvalidDeviceCode => 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional DTOs (used by service, not in the handler module)
|
||||
// ============================================================================
|
||||
|
||||
/// DTO for listing authorized devices in the user's profile.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeviceInfoDto {
|
||||
pub id: String,
|
||||
pub client_name: String,
|
||||
pub scopes: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub authorized_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a cryptographically random device_code (hex-encoded).
|
||||
fn generate_device_code() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut bytes = [0u8; DEVICE_CODE_BYTES];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Generate a human-readable user_code in the format "ABCD-1234".
|
||||
fn generate_user_code() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut rng_bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut rng_bytes);
|
||||
|
||||
let letters: String = (0..USER_CODE_LETTER_LEN)
|
||||
.map(|i| {
|
||||
let b = rng_bytes[i] % 26;
|
||||
(b'A' + b) as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let digits: String = (0..USER_CODE_DIGIT_LEN)
|
||||
.map(|i| {
|
||||
let b = rng_bytes[USER_CODE_LETTER_LEN + i] % 10;
|
||||
(b'0' + b) as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!("{}-{}", letters, digits)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -199,7 +199,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
tokio::fs::write(temp.path(), content)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("write temp: {e}")))?;
|
||||
let hash = hex::encode(Sha256::digest(content));
|
||||
let hash = blake3::hash(content).to_hex().to_string();
|
||||
|
||||
let file = self
|
||||
.file_write
|
||||
@@ -228,7 +228,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
tokio::fs::write(temp.path(), content)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("write temp: {e}")))?;
|
||||
let hash = hex::encode(Sha256::digest(content));
|
||||
let hash = blake3::hash(content).to_hex().to_string();
|
||||
|
||||
self.update_file_streaming(
|
||||
path,
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::application::dtos::folder_dto::{
|
||||
};
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::application::transactions::storage_transaction::StorageTransaction;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use async_trait::async_trait;
|
||||
@@ -398,52 +397,11 @@ impl FolderUseCase for FolderService {
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
|
||||
// Create transaction for renaming
|
||||
let mut transaction = StorageTransaction::new("rename_folder");
|
||||
|
||||
// Main operation: rename folder
|
||||
// Clone all values to avoid lifetime issues
|
||||
let folder_storage = self.folder_storage.clone();
|
||||
let id_owned = id.to_string();
|
||||
let name_owned = dto.name.clone();
|
||||
|
||||
// Create future with owned values
|
||||
let rename_op = async move {
|
||||
folder_storage.rename_folder(&id_owned, name_owned).await?;
|
||||
Ok(())
|
||||
};
|
||||
let rollback_op = {
|
||||
let original_name = existing_folder.name().to_string();
|
||||
let storage = self.folder_storage.clone();
|
||||
let id_clone = id.to_string();
|
||||
|
||||
async move {
|
||||
// In case of failure, restore the original name
|
||||
storage
|
||||
.rename_folder(&id_clone, original_name)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Failed to rollback folder rename: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Add to the transaction
|
||||
transaction.add_operation(rename_op, rollback_op);
|
||||
|
||||
// Execute transaction
|
||||
transaction.commit().await?;
|
||||
|
||||
// Get the renamed folder
|
||||
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
// Rename folder — UPDATE RETURNING gives us the updated row directly
|
||||
let folder = self.folder_storage.rename_folder(id, dto.name).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get renamed folder with ID: {}: {}", id, e),
|
||||
format!("Failed to rename folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -495,55 +453,12 @@ impl FolderUseCase for FolderService {
|
||||
// TODO: Ideally we should verify the entire hierarchy to prevent cycles
|
||||
}
|
||||
|
||||
// Create transaction for moving
|
||||
let mut transaction = StorageTransaction::new("move_folder");
|
||||
|
||||
// Main operation: move folder
|
||||
// Clone all values to avoid lifetime issues
|
||||
let folder_storage = self.folder_storage.clone();
|
||||
let id_owned = id.to_string();
|
||||
// Get parent ID as owned string or None
|
||||
let parent_id_owned = dto.parent_id.as_ref().map(|p| p.to_string());
|
||||
|
||||
// Create future with owned values
|
||||
let move_op = async move {
|
||||
// Convert Option<String> to Option<&str>
|
||||
let parent_ref = parent_id_owned.as_deref();
|
||||
folder_storage.move_folder(&id_owned, parent_ref).await?;
|
||||
Ok(())
|
||||
};
|
||||
let rollback_op = {
|
||||
let original_parent_id = source_folder.parent_id().map(String::from);
|
||||
let storage = self.folder_storage.clone();
|
||||
let id_clone = id.to_string();
|
||||
|
||||
async move {
|
||||
// In case of failure, restore the original location
|
||||
storage
|
||||
.move_folder(&id_clone, original_parent_id.as_deref())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Failed to rollback folder move: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Add to the transaction
|
||||
transaction.add_operation(move_op, rollback_op);
|
||||
|
||||
// Execute transaction
|
||||
transaction.commit().await?;
|
||||
|
||||
// Get the moved folder
|
||||
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
// Move folder — UPDATE RETURNING gives us the updated row directly
|
||||
let parent_ref = dto.parent_id.as_deref();
|
||||
let folder = self.folder_storage.move_folder(id, parent_ref).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get moved folder with ID: {}: {}", id, e),
|
||||
format!("Failed to move folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod admin_settings_service;
|
||||
pub mod app_password_service;
|
||||
pub mod auth_application_service;
|
||||
pub mod batch_operations;
|
||||
pub mod calendar_service;
|
||||
pub mod device_auth_service;
|
||||
pub mod contact_service;
|
||||
pub mod favorites_service;
|
||||
pub mod file_management_service;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use std::cmp::Reverse;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -323,15 +324,13 @@ impl SearchUseCase for SearchService {
|
||||
.map(|f| Self::enrich_folder(f, query))
|
||||
.collect();
|
||||
|
||||
// Sort folders
|
||||
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
|
||||
match criteria.sort_by.as_str() {
|
||||
"name" => {
|
||||
enriched_folders
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
|
||||
}
|
||||
"name_desc" => {
|
||||
enriched_folders
|
||||
.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
|
||||
}
|
||||
"date" => {
|
||||
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
@@ -411,13 +410,13 @@ impl SearchUseCase for SearchService {
|
||||
.map(|f| Self::enrich_folder(f, query))
|
||||
.collect();
|
||||
|
||||
// ── Sort folders (files already sorted by SQL ORDER BY) ──
|
||||
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
|
||||
match criteria.sort_by.as_str() {
|
||||
"name" => {
|
||||
enriched_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
|
||||
}
|
||||
"name_desc" => {
|
||||
enriched_folders.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
|
||||
}
|
||||
"date" => {
|
||||
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod storage_transaction;
|
||||
@@ -1,150 +0,0 @@
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Type for async operations and rollbacks
|
||||
type TransactionOp = Pin<Box<dyn Future<Output = Result<(), DomainError>> + Send>>;
|
||||
|
||||
/// Transaction for storage operations
|
||||
/// Allows defining a set of operations and their corresponding rollbacks
|
||||
pub struct StorageTransaction {
|
||||
/// Operations to execute
|
||||
operations: Vec<Box<dyn FnOnce() -> TransactionOp + Send>>,
|
||||
/// Rollback operations to revert changes in case of error
|
||||
rollbacks: Vec<Box<dyn FnOnce() -> TransactionOp + Send>>,
|
||||
/// Transaction name for logging
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl StorageTransaction {
|
||||
/// Creates a new transaction
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
operations: Vec::new(),
|
||||
rollbacks: Vec::new(),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an operation to the transaction with its corresponding rollback
|
||||
pub fn add_operation<F, R>(&mut self, operation: F, rollback: R)
|
||||
where
|
||||
F: Future<Output = Result<(), DomainError>> + Send + 'static,
|
||||
R: Future<Output = Result<(), DomainError>> + Send + 'static,
|
||||
{
|
||||
self.operations.push(Box::new(move || Box::pin(operation)));
|
||||
self.rollbacks.push(Box::new(move || Box::pin(rollback)));
|
||||
}
|
||||
|
||||
/// Adds an operation without rollback (for cleanup or logging)
|
||||
pub fn add_finalizer<F>(&mut self, finalizer: F)
|
||||
where
|
||||
F: Future<Output = Result<(), DomainError>> + Send + 'static,
|
||||
{
|
||||
// The rollback is a no-op
|
||||
let noop = async { Ok(()) };
|
||||
|
||||
self.operations.push(Box::new(move || Box::pin(finalizer)));
|
||||
self.rollbacks.push(Box::new(move || Box::pin(noop)));
|
||||
}
|
||||
|
||||
/// Executes the transaction by applying all operations in order
|
||||
/// If any fails, executes rollbacks in reverse order
|
||||
pub async fn commit(mut self) -> Result<(), DomainError> {
|
||||
tracing::debug!("Starting transaction: {}", self.name);
|
||||
|
||||
let mut completed_ops = Vec::new();
|
||||
|
||||
// Extract operations to avoid ownership issues
|
||||
let operations = std::mem::take(&mut self.operations);
|
||||
let transaction_name = self.name.clone();
|
||||
|
||||
// Execute operations
|
||||
for (i, op) in operations.into_iter().enumerate() {
|
||||
match op().await {
|
||||
Ok(()) => {
|
||||
completed_ops.push(i);
|
||||
tracing::trace!(
|
||||
"Operation {} completed in transaction: {}",
|
||||
i,
|
||||
transaction_name
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error in operation {} of transaction {}: {}",
|
||||
i,
|
||||
transaction_name,
|
||||
e
|
||||
);
|
||||
|
||||
// Execute rollbacks for completed operations in reverse order
|
||||
self.rollback(completed_ops).await?;
|
||||
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Transaction",
|
||||
format!("Transaction '{}' failed: {}", transaction_name, e),
|
||||
)
|
||||
.with_source(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Transaction completed successfully: {}", transaction_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Executes rollbacks for completed operations
|
||||
async fn rollback(mut self, completed_ops: Vec<usize>) -> Result<(), DomainError> {
|
||||
tracing::warn!("Starting rollback for transaction: {}", self.name);
|
||||
|
||||
let mut rollback_errors = Vec::new();
|
||||
|
||||
// Extract rollbacks to avoid ownership issues
|
||||
let mut rollbacks = Vec::new();
|
||||
std::mem::swap(&mut rollbacks, &mut self.rollbacks);
|
||||
|
||||
// Execute rollbacks in reverse order
|
||||
for i in completed_ops.into_iter().rev() {
|
||||
if i < rollbacks.len() {
|
||||
// Take ownership of the rollback (get a mutable reference)
|
||||
if let Some(rb) = rollbacks.get_mut(i) {
|
||||
// Swap with an empty function
|
||||
let rollback = std::mem::replace(rb, Box::new(|| Box::pin(async { Ok(()) })));
|
||||
if let Err(e) = rollback().await {
|
||||
tracing::error!(
|
||||
"Error in rollback of operation {} in transaction {}: {}",
|
||||
i,
|
||||
self.name,
|
||||
e
|
||||
);
|
||||
rollback_errors.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were errors during rollback, report them
|
||||
if !rollback_errors.is_empty() {
|
||||
tracing::error!(
|
||||
"Errors during transaction rollback {}: {} errors",
|
||||
self.name,
|
||||
rollback_errors.len()
|
||||
);
|
||||
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Transaction",
|
||||
format!(
|
||||
"Errors during transaction '{}' rollback: {} errors",
|
||||
self.name,
|
||||
rollback_errors.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!("Transaction rollback completed: {}", self.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -540,6 +540,8 @@ impl AppServiceFactory {
|
||||
wopi_token_service: None,
|
||||
wopi_lock_service: None,
|
||||
wopi_discovery_service: None,
|
||||
device_auth_service: None,
|
||||
app_password_service: None,
|
||||
};
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
@@ -589,6 +591,64 @@ impl AppServiceFactory {
|
||||
}
|
||||
|
||||
app_state.admin_settings_service = Some(admin_svc);
|
||||
|
||||
// 9c. Wire Device Authorization Grant (RFC 8628) service
|
||||
{
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
||||
|
||||
let device_code_repo = Arc::new(DeviceCodePgRepository::new(pool.clone()));
|
||||
let user_repo: Arc<dyn crate::application::ports::auth_ports::UserStoragePort> =
|
||||
Arc::new(crate::infrastructure::repositories::UserPgRepository::new(
|
||||
pool.clone(),
|
||||
));
|
||||
let session_repo: Arc<dyn crate::application::ports::auth_ports::SessionStoragePort> =
|
||||
Arc::new(crate::infrastructure::repositories::SessionPgRepository::new(
|
||||
pool.clone(),
|
||||
));
|
||||
let base_url = self.config.base_url();
|
||||
|
||||
let device_auth_svc = Arc::new(DeviceAuthService::new(
|
||||
device_code_repo,
|
||||
auth_svc.token_service.clone(),
|
||||
user_repo,
|
||||
session_repo,
|
||||
base_url,
|
||||
));
|
||||
app_state.device_auth_service = Some(device_auth_svc);
|
||||
tracing::info!("Device Authorization Grant (RFC 8628) service initialized");
|
||||
}
|
||||
|
||||
// 9d. Wire App Password service
|
||||
{
|
||||
use crate::application::services::app_password_service::AppPasswordService;
|
||||
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
||||
|
||||
let app_pw_repo: Arc<dyn crate::application::ports::auth_ports::AppPasswordStoragePort> =
|
||||
Arc::new(AppPasswordPgRepository::new(pool.clone()));
|
||||
let hasher: Arc<dyn crate::application::ports::auth_ports::PasswordHasherPort> =
|
||||
Arc::new(
|
||||
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
|
||||
self.config.auth.hash_memory_cost,
|
||||
self.config.auth.hash_time_cost,
|
||||
self.config.auth.hash_parallelism,
|
||||
),
|
||||
);
|
||||
let user_repo: Arc<dyn crate::application::ports::auth_ports::UserStoragePort> =
|
||||
Arc::new(crate::infrastructure::repositories::UserPgRepository::new(
|
||||
pool.clone(),
|
||||
));
|
||||
let base_url = self.config.base_url();
|
||||
|
||||
let app_pw_svc = Arc::new(AppPasswordService::new(
|
||||
app_pw_repo,
|
||||
hasher,
|
||||
user_repo,
|
||||
base_url,
|
||||
));
|
||||
app_state.app_password_service = Some(app_pw_svc);
|
||||
tracing::info!("App Password service initialized");
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Wire CalDAV/CardDAV services
|
||||
@@ -782,6 +842,10 @@ pub struct AppState {
|
||||
Option<Arc<crate::application::services::wopi_lock_service::WopiLockService>>,
|
||||
pub wopi_discovery_service:
|
||||
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
|
||||
pub device_auth_service:
|
||||
Option<Arc<crate::application::services::device_auth_service::DeviceAuthService>>,
|
||||
pub app_password_service:
|
||||
Option<Arc<crate::application::services::app_password_service::AppPasswordService>>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! App Password entity.
|
||||
//!
|
||||
//! Represents an application-specific password that clients (like DAVx⁵, Thunderbird)
|
||||
//! can use with HTTP Basic Auth to access WebDAV/CalDAV/CardDAV endpoints without
|
||||
//! requiring interactive OAuth flows.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// An application password created by a user for a specific client.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppPassword {
|
||||
/// Unique identifier.
|
||||
pub id: String,
|
||||
/// Owner user ID.
|
||||
pub user_id: String,
|
||||
/// Human-readable label chosen by the user (e.g. "DAVx5 on Pixel 8").
|
||||
pub label: String,
|
||||
/// Argon2 hash of the generated password token.
|
||||
///
|
||||
/// The plain text token is only returned once at creation time.
|
||||
pub password_hash: String,
|
||||
/// First 8 characters of the plain text token, stored for display purposes
|
||||
/// so the user can identify which token is which.
|
||||
pub prefix: String,
|
||||
/// Comma-separated scopes (e.g. "webdav,caldav,carddav").
|
||||
pub scopes: String,
|
||||
/// When this app password was created.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When this app password was last used for authentication.
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
/// Optional expiry — `None` means never expires.
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Whether this app password is active.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl AppPassword {
|
||||
/// Create a new app password entity.
|
||||
///
|
||||
/// The caller is responsible for hashing the raw token and passing
|
||||
/// the hash and prefix.
|
||||
pub fn new(
|
||||
user_id: String,
|
||||
label: String,
|
||||
password_hash: String,
|
||||
prefix: String,
|
||||
scopes: String,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id,
|
||||
label,
|
||||
password_hash,
|
||||
prefix,
|
||||
scopes,
|
||||
created_at: Utc::now(),
|
||||
last_used_at: None,
|
||||
expires_at,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether this app password has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
if let Some(exp) = self.expires_at {
|
||||
Utc::now() >= exp
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether this app password is usable (active and not expired).
|
||||
pub fn is_usable(&self) -> bool {
|
||||
self.active && !self.is_expired()
|
||||
}
|
||||
|
||||
/// Check whether the given scope is granted by this app password.
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.split(',').any(|s| s.trim() == scope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Device Authorization Code entity (RFC 8628).
|
||||
//!
|
||||
//! Represents a pending or completed OAuth 2.0 Device Authorization Grant flow.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Status of a device authorization flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceCodeStatus {
|
||||
/// Waiting for the user to authorize on the verification page.
|
||||
Pending,
|
||||
/// User approved — tokens are ready for the polling client.
|
||||
Authorized,
|
||||
/// User explicitly denied the request.
|
||||
Denied,
|
||||
/// The code expired before the user acted.
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl DeviceCodeStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Authorized => "authorized",
|
||||
Self::Denied => "denied",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"pending" => Some(Self::Pending),
|
||||
"authorized" => Some(Self::Authorized),
|
||||
"denied" => Some(Self::Denied),
|
||||
"expired" => Some(Self::Expired),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeviceCodeStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain entity for a Device Authorization flow.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceCode {
|
||||
id: String,
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
client_name: String,
|
||||
scopes: String,
|
||||
status: DeviceCodeStatus,
|
||||
user_id: Option<String>,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_at: DateTime<Utc>,
|
||||
poll_interval_secs: i32,
|
||||
last_poll_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
authorized_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl DeviceCode {
|
||||
/// Create a new pending device code flow.
|
||||
///
|
||||
/// * `device_code` — opaque token for client polling (64 hex chars)
|
||||
/// * `user_code` — short human-readable code (e.g. "ABCD-1234")
|
||||
/// * `client_name` — display name of the requesting client
|
||||
/// * `scopes` — requested scopes (e.g. "webdav,caldav,carddav")
|
||||
/// * `verification_uri` — URL the user must visit
|
||||
/// * `expires_in_secs` — TTL for the device code
|
||||
/// * `poll_interval_secs` — minimum polling interval
|
||||
pub fn new(
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
client_name: String,
|
||||
scopes: String,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_in_secs: i64,
|
||||
poll_interval_secs: i32,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
device_code,
|
||||
user_code,
|
||||
client_name,
|
||||
scopes,
|
||||
status: DeviceCodeStatus::Pending,
|
||||
user_id: None,
|
||||
access_token: None,
|
||||
refresh_token: None,
|
||||
verification_uri,
|
||||
verification_uri_complete,
|
||||
expires_at: now + Duration::seconds(expires_in_secs),
|
||||
poll_interval_secs,
|
||||
last_poll_at: None,
|
||||
created_at: now,
|
||||
authorized_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from database row.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_raw(
|
||||
id: String,
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
client_name: String,
|
||||
scopes: String,
|
||||
status: DeviceCodeStatus,
|
||||
user_id: Option<String>,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_at: DateTime<Utc>,
|
||||
poll_interval_secs: i32,
|
||||
last_poll_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
authorized_at: Option<DateTime<Utc>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
device_code,
|
||||
user_code,
|
||||
client_name,
|
||||
scopes,
|
||||
status,
|
||||
user_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
verification_uri,
|
||||
verification_uri_complete,
|
||||
expires_at,
|
||||
poll_interval_secs,
|
||||
last_poll_at,
|
||||
created_at,
|
||||
authorized_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Getters ──────────────────────────────────────────────────
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn device_code(&self) -> &str {
|
||||
&self.device_code
|
||||
}
|
||||
|
||||
pub fn user_code(&self) -> &str {
|
||||
&self.user_code
|
||||
}
|
||||
|
||||
pub fn client_name(&self) -> &str {
|
||||
&self.client_name
|
||||
}
|
||||
|
||||
pub fn scopes(&self) -> &str {
|
||||
&self.scopes
|
||||
}
|
||||
|
||||
pub fn status(&self) -> DeviceCodeStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Option<&str> {
|
||||
self.user_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> Option<&str> {
|
||||
self.access_token.as_deref()
|
||||
}
|
||||
|
||||
pub fn refresh_token(&self) -> Option<&str> {
|
||||
self.refresh_token.as_deref()
|
||||
}
|
||||
|
||||
pub fn verification_uri(&self) -> &str {
|
||||
&self.verification_uri
|
||||
}
|
||||
|
||||
pub fn verification_uri_complete(&self) -> Option<&str> {
|
||||
self.verification_uri_complete.as_deref()
|
||||
}
|
||||
|
||||
pub fn expires_at(&self) -> DateTime<Utc> {
|
||||
self.expires_at
|
||||
}
|
||||
|
||||
pub fn poll_interval_secs(&self) -> i32 {
|
||||
self.poll_interval_secs
|
||||
}
|
||||
|
||||
pub fn last_poll_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.last_poll_at
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
pub fn authorized_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.authorized_at
|
||||
}
|
||||
|
||||
// ── Business logic ───────────────────────────────────────────
|
||||
|
||||
/// Whether the device code has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
|
||||
/// Seconds remaining until expiry (clamped to 0).
|
||||
pub fn seconds_remaining(&self) -> i64 {
|
||||
let remaining = (self.expires_at - Utc::now()).num_seconds();
|
||||
remaining.max(0)
|
||||
}
|
||||
|
||||
/// Whether the client is polling too fast (within poll_interval_secs).
|
||||
pub fn is_polling_too_fast(&self) -> bool {
|
||||
if let Some(last) = self.last_poll_at {
|
||||
let elapsed = (Utc::now() - last).num_seconds();
|
||||
elapsed < self.poll_interval_secs as i64
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a poll attempt timestamp.
|
||||
pub fn record_poll(&mut self) {
|
||||
self.last_poll_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Authorize this device code for a specific user, storing the tokens.
|
||||
pub fn authorize(
|
||||
&mut self,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
) {
|
||||
self.status = DeviceCodeStatus::Authorized;
|
||||
self.user_id = Some(user_id);
|
||||
self.access_token = Some(access_token);
|
||||
self.refresh_token = Some(refresh_token);
|
||||
self.authorized_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Deny this device code.
|
||||
pub fn deny(&mut self) {
|
||||
self.status = DeviceCodeStatus::Denied;
|
||||
}
|
||||
|
||||
/// Mark as expired.
|
||||
pub fn mark_expired(&mut self) {
|
||||
self.status = DeviceCodeStatus::Expired;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod app_password;
|
||||
pub mod calendar;
|
||||
pub mod calendar_event;
|
||||
pub mod contact;
|
||||
pub mod device_code;
|
||||
pub mod entity_errors;
|
||||
pub mod file;
|
||||
pub mod folder;
|
||||
|
||||
@@ -3,6 +3,6 @@ pub mod pg;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
pub use pg::{
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, SessionPgRepository,
|
||||
TrashDbRepository, UserPgRepository,
|
||||
AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository,
|
||||
FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! PostgreSQL repository for App Passwords.
|
||||
|
||||
use crate::application::ports::auth_ports::AppPasswordStoragePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppPasswordPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl AppPasswordPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||
async fn create(&self, ap: AppPassword) -> Result<AppPassword, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.app_passwords
|
||||
(id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&ap.id)
|
||||
.bind(&ap.user_id)
|
||||
.bind(&ap.label)
|
||||
.bind(&ap.password_hash)
|
||||
.bind(&ap.prefix)
|
||||
.bind(&ap.scopes)
|
||||
.bind(ap.created_at)
|
||||
.bind(ap.last_used_at)
|
||||
.bind(ap.expires_at)
|
||||
.bind(ap.active)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("create: {e}")))?;
|
||||
|
||||
Ok(ap)
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("list: {e}")))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &str) -> Result<AppPassword, DomainError> {
|
||||
let row = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_by_id: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("AppPassword", id))?;
|
||||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn get_active_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
AND active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("get_active: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("touch: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
||||
let result =
|
||||
sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("revoke: {e}"))
|
||||
})?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("AppPassword", id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.app_passwords
|
||||
WHERE (active = FALSE)
|
||||
OR (expires_at IS NOT NULL AND expires_at < NOW())
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("delete_expired: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal row struct for sqlx mapping.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppPasswordRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
label: String,
|
||||
password_hash: String,
|
||||
prefix: String,
|
||||
scopes: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl From<AppPasswordRow> for AppPassword {
|
||||
fn from(r: AppPasswordRow) -> Self {
|
||||
AppPassword {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
label: r.label,
|
||||
password_hash: r.password_hash,
|
||||
prefix: r.prefix,
|
||||
scopes: r.scopes,
|
||||
created_at: r.created_at,
|
||||
last_used_at: r.last_used_at,
|
||||
expires_at: r.expires_at,
|
||||
active: r.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! PostgreSQL repository for Device Authorization Grant (RFC 8628) codes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::auth_ports::DeviceCodeStoragePort;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||
|
||||
pub struct DeviceCodePgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl DeviceCodePgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<DeviceCode, DomainError> {
|
||||
let status_str: String = row.try_get("status").map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to read status: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired);
|
||||
|
||||
Ok(DeviceCode::from_raw(
|
||||
row.try_get("id").unwrap_or_default(),
|
||||
row.try_get("device_code").unwrap_or_default(),
|
||||
row.try_get("user_code").unwrap_or_default(),
|
||||
row.try_get("client_name").unwrap_or_default(),
|
||||
row.try_get("scopes").unwrap_or_default(),
|
||||
status,
|
||||
row.try_get("user_id").ok(),
|
||||
row.try_get("access_token").ok(),
|
||||
row.try_get("refresh_token").ok(),
|
||||
row.try_get("verification_uri").unwrap_or_default(),
|
||||
row.try_get("verification_uri_complete").ok(),
|
||||
row.try_get("expires_at").unwrap_or_default(),
|
||||
row.try_get::<i32, _>("poll_interval_secs").unwrap_or(5),
|
||||
row.try_get("last_poll_at").ok(),
|
||||
row.try_get("created_at").unwrap_or_default(),
|
||||
row.try_get("authorized_at").ok(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceCodeStoragePort for DeviceCodePgRepository {
|
||||
async fn create_device_code(&self, dc: DeviceCode) -> Result<DeviceCode, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.device_codes (
|
||||
id, device_code, user_code, client_name, scopes, status,
|
||||
user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6::auth.device_code_status,
|
||||
$7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(dc.id())
|
||||
.bind(dc.device_code())
|
||||
.bind(dc.user_code())
|
||||
.bind(dc.client_name())
|
||||
.bind(dc.scopes())
|
||||
.bind(dc.status().as_str())
|
||||
.bind(dc.user_id())
|
||||
.bind(dc.access_token())
|
||||
.bind(dc.refresh_token())
|
||||
.bind(dc.verification_uri())
|
||||
.bind(dc.verification_uri_complete())
|
||||
.bind(dc.expires_at())
|
||||
.bind(dc.poll_interval_secs())
|
||||
.bind(dc.last_poll_at())
|
||||
.bind(dc.created_at())
|
||||
.bind(dc.authorized_at())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to create device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(dc)
|
||||
}
|
||||
|
||||
async fn get_by_device_code(&self, device_code: &str) -> Result<DeviceCode, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE device_code = $1
|
||||
"#,
|
||||
)
|
||||
.bind(device_code)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"Device code not found",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to fetch device code: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn get_pending_by_user_code(&self, user_code: &str) -> Result<DeviceCode, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE user_code = $1
|
||||
AND status = 'pending'
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(user_code)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"User code not found or expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to fetch by user code: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn update_device_code(&self, dc: DeviceCode) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.device_codes SET
|
||||
status = $2::auth.device_code_status,
|
||||
user_id = $3,
|
||||
access_token = $4,
|
||||
refresh_token = $5,
|
||||
last_poll_at = $6,
|
||||
authorized_at = $7
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(dc.id())
|
||||
.bind(dc.status().as_str())
|
||||
.bind(dc.user_id())
|
||||
.bind(dc.access_token())
|
||||
.bind(dc.refresh_token())
|
||||
.bind(dc.last_poll_at())
|
||||
.bind(dc.authorized_at())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to update device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.device_codes
|
||||
WHERE expires_at < NOW()
|
||||
AND status IN ('pending', 'expired')
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to delete expired device codes: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<DeviceCode>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to list device codes: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
rows.iter().map(Self::map_row).collect()
|
||||
}
|
||||
|
||||
async fn delete_by_id(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM auth.device_codes WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to delete device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,12 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
COALESCE(f.updated_at, fld.updated_at) AS "modified_at"
|
||||
FROM auth.user_favorites uf
|
||||
LEFT JOIN storage.files f ON uf.item_type = 'file'
|
||||
AND uf.item_id = f.id::TEXT
|
||||
AND f.id = uf.item_id::UUID
|
||||
LEFT JOIN storage.folders fld ON uf.item_type = 'folder'
|
||||
AND uf.item_id = fld.id::TEXT
|
||||
AND fld.id = uf.item_id::UUID
|
||||
WHERE uf.user_id = $1::TEXT
|
||||
ORDER BY uf.created_at DESC
|
||||
LIMIT 500
|
||||
"#,
|
||||
)
|
||||
.bind(user_uuid)
|
||||
|
||||
@@ -532,10 +532,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
}
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& !name.is_empty()
|
||||
&& name.len() >= 3
|
||||
{
|
||||
bind_idx += 1;
|
||||
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
|
||||
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
@@ -578,9 +578,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
query = query.bind(fid);
|
||||
}
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& !name.is_empty()
|
||||
&& name.len() >= 3
|
||||
{
|
||||
query = query.bind(format!("%{}%", name.to_lowercase()));
|
||||
query = query.bind(format!("%{}%", name));
|
||||
}
|
||||
query = query.bind(limit).bind(offset);
|
||||
|
||||
@@ -652,10 +652,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
);
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& !name.is_empty()
|
||||
&& name.len() >= 3
|
||||
{
|
||||
bind_idx += 1;
|
||||
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
|
||||
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
|
||||
}
|
||||
if let Some(types) = &criteria.file_types
|
||||
&& !types.is_empty()
|
||||
@@ -737,9 +737,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.bind(root_id);
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& !name.is_empty()
|
||||
&& name.len() >= 3
|
||||
{
|
||||
query = query.bind(format!("%{}%", name.to_lowercase()));
|
||||
query = query.bind(format!("%{}%", name));
|
||||
}
|
||||
if let Some(types) = &criteria.file_types
|
||||
&& !types.is_empty()
|
||||
@@ -807,9 +807,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let pattern = format!("%{}%", query.to_lowercase());
|
||||
let pattern = format!("%{}%", query);
|
||||
let limit_i64 = limit as i64;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
@@ -833,10 +832,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id = $1::uuid
|
||||
AND NOT fi.is_trashed
|
||||
AND LOWER(fi.name) LIKE $2
|
||||
AND fi.name ILIKE $2
|
||||
ORDER BY CASE
|
||||
WHEN LOWER(fi.name) = $3 THEN 0
|
||||
WHEN LOWER(fi.name) LIKE $3 || '%' THEN 1
|
||||
WHEN fi.name ILIKE $3 THEN 0
|
||||
WHEN fi.name ILIKE $3 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
fi.name
|
||||
@@ -845,7 +844,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
)
|
||||
.bind(fid)
|
||||
.bind(&pattern)
|
||||
.bind(&query_lower)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
@@ -861,10 +860,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id IS NULL
|
||||
AND NOT fi.is_trashed
|
||||
AND LOWER(fi.name) LIKE $1
|
||||
AND fi.name ILIKE $1
|
||||
ORDER BY CASE
|
||||
WHEN LOWER(fi.name) = $2 THEN 0
|
||||
WHEN LOWER(fi.name) LIKE $2 || '%' THEN 1
|
||||
WHEN fi.name ILIKE $2 THEN 0
|
||||
WHEN fi.name ILIKE $2 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
fi.name
|
||||
@@ -872,7 +871,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
"#,
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&query_lower)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
|
||||
@@ -258,6 +258,9 @@ impl FolderRepository for FolderDbRepository {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Paginated folder listing — single query with `COUNT(*) OVER()` window
|
||||
/// function so the total matching count comes back alongside the data rows,
|
||||
/// eliminating a separate COUNT round-trip.
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -265,34 +268,14 @@ impl FolderRepository for FolderDbRepository {
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let total = if include_total {
|
||||
let count: i64 = if let Some(pid) = parent_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed",
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed",
|
||||
)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("count: {e}")))?;
|
||||
Some(count as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
||||
if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -309,7 +292,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -323,15 +307,24 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||
|
||||
// total_count is identical in every row; 0 when the result set is empty.
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.7) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
|
||||
/// Paginated folder listing filtered by owner — single query with
|
||||
/// `COUNT(*) OVER()` to avoid a separate COUNT round-trip.
|
||||
async fn list_folders_by_owner_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -340,36 +333,14 @@ impl FolderRepository for FolderDbRepository {
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let total = if include_total {
|
||||
let count: i64 = if let Some(pid) = parent_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed",
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("count_by_owner: {e}")))?;
|
||||
Some(count as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
||||
if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -387,7 +358,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -404,9 +376,15 @@ impl FolderRepository for FolderDbRepository {
|
||||
DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}"))
|
||||
})?;
|
||||
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.7) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||
})
|
||||
.collect();
|
||||
@@ -417,16 +395,19 @@ impl FolderRepository for FolderDbRepository {
|
||||
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
||||
// the AFTER UPDATE cascade trigger then batch-updates all
|
||||
// descendants in a single UPDATE using the GiST lpath index.
|
||||
sqlx::query(
|
||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET name = $1, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e
|
||||
@@ -435,9 +416,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
return DomainError::already_exists("Folder", format!("{new_name} already exists"));
|
||||
}
|
||||
DomainError::internal_error("FolderDb", format!("rename: {e}"))
|
||||
})?;
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
self.get_folder(id).await
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||
}
|
||||
|
||||
async fn move_folder(
|
||||
@@ -448,20 +430,24 @@ impl FolderRepository for FolderDbRepository {
|
||||
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
||||
// the AFTER UPDATE cascade trigger then batch-updates all
|
||||
// descendants in a single UPDATE using the GiST lpath index.
|
||||
sqlx::query(
|
||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET parent_id = $1::uuid, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(new_parent_id)
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?;
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
self.get_folder(id).await
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
||||
@@ -734,15 +720,16 @@ impl FolderRepository for FolderDbRepository {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Build optional name filter
|
||||
// Build optional name filter — use ILIKE (case-insensitive) so the
|
||||
// GIN trigram index idx_folders_name_trgm is used instead of a seq scan.
|
||||
let (name_clause, name_pattern) = match name_contains {
|
||||
Some(name) if !name.is_empty() => (
|
||||
Some(name) if name.len() >= 3 => (
|
||||
if recursive {
|
||||
" AND LOWER(fo.name) LIKE $2"
|
||||
" AND fo.name ILIKE $2"
|
||||
} else {
|
||||
" AND LOWER(fo.name) LIKE $3"
|
||||
" AND fo.name ILIKE $3"
|
||||
},
|
||||
Some(format!("%{}%", name.to_lowercase())),
|
||||
Some(format!("%{}%", name)),
|
||||
),
|
||||
_ => ("", None),
|
||||
};
|
||||
@@ -808,7 +795,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
|
||||
let name_clause_root = match name_contains {
|
||||
Some(name) if !name.is_empty() => " AND LOWER(fo.name) LIKE $2",
|
||||
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
@@ -880,9 +867,9 @@ impl FolderRepository for FolderDbRepository {
|
||||
user_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let (where_extra, name_pattern) = match name_contains {
|
||||
Some(name) if !name.is_empty() => (
|
||||
" AND LOWER(fo.name) LIKE $3",
|
||||
Some(format!("%{}%", name.to_lowercase())),
|
||||
Some(name) if name.len() >= 3 => (
|
||||
" AND fo.name ILIKE $3",
|
||||
Some(format!("%{}%", name)),
|
||||
),
|
||||
_ => ("", None),
|
||||
};
|
||||
@@ -938,8 +925,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let pattern = format!("%{}%", query.to_lowercase());
|
||||
let query_lower = query.to_lowercase();
|
||||
let pattern = format!("%{}%", query);
|
||||
let limit_i64 = limit as i64;
|
||||
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
|
||||
@@ -952,10 +938,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid
|
||||
AND NOT is_trashed
|
||||
AND LOWER(name) LIKE $2
|
||||
AND name ILIKE $2
|
||||
ORDER BY CASE
|
||||
WHEN LOWER(name) = $3 THEN 0
|
||||
WHEN LOWER(name) LIKE $3 || '%' THEN 1
|
||||
WHEN name ILIKE $3 THEN 0
|
||||
WHEN name ILIKE $3 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
name
|
||||
@@ -964,7 +950,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(&pattern)
|
||||
.bind(&query_lower)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
@@ -977,10 +963,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL
|
||||
AND NOT is_trashed
|
||||
AND LOWER(name) LIKE $1
|
||||
AND name ILIKE $1
|
||||
ORDER BY CASE
|
||||
WHEN LOWER(name) = $2 THEN 0
|
||||
WHEN LOWER(name) LIKE $2 || '%' THEN 1
|
||||
WHEN name ILIKE $2 THEN 0
|
||||
WHEN name ILIKE $2 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
name
|
||||
@@ -988,7 +974,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
"#,
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&query_lower)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod address_book_pg_repository;
|
||||
mod app_password_pg_repository;
|
||||
mod calendar_event_pg_repository;
|
||||
mod calendar_pg_repository;
|
||||
mod contact_group_pg_repository;
|
||||
mod contact_persistence_dto;
|
||||
mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
mod recent_items_pg_repository;
|
||||
mod session_pg_repository;
|
||||
@@ -19,11 +21,13 @@ pub mod folder_db_repository;
|
||||
pub mod trash_db_repository;
|
||||
|
||||
pub use address_book_pg_repository::AddressBookPgRepository;
|
||||
pub use app_password_pg_repository::AppPasswordPgRepository;
|
||||
pub use calendar_event_pg_repository::CalendarEventPgRepository;
|
||||
pub use calendar_pg_repository::CalendarPgRepository;
|
||||
pub use contact_group_pg_repository::ContactGroupPgRepository;
|
||||
pub use contact_persistence_dto::*;
|
||||
pub use contact_pg_repository::ContactPgRepository;
|
||||
pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
|
||||
@@ -38,9 +38,9 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id"
|
||||
FROM auth.user_recent_files ur
|
||||
LEFT JOIN storage.files f ON ur.item_type = 'file'
|
||||
AND ur.item_id = f.id::TEXT
|
||||
AND f.id = ur.item_id::UUID
|
||||
LEFT JOIN storage.folders fld ON ur.item_type = 'folder'
|
||||
AND ur.item_id = fld.id::TEXT
|
||||
AND fld.id = ur.item_id::UUID
|
||||
WHERE ur.user_id = $1::TEXT
|
||||
ORDER BY ur.accessed_at DESC
|
||||
LIMIT $2
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (updated atomically on each chunk) are stored alongside the chunk files.
|
||||
//! On boot the service scans `temp_base_dir` and recovers any active sessions.
|
||||
//! - Parallel chunk transfers (up to 6 concurrent)
|
||||
//! - Automatic reassembly with hash-on-write (SHA-256)
|
||||
//! - Automatic reassembly with hash-on-write (BLAKE3)
|
||||
//! - Expiration cleanup (24 h)
|
||||
//!
|
||||
//! Protocol:
|
||||
@@ -19,7 +19,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -592,13 +592,13 @@ impl ChunkedUploadService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Assemble chunks into final file and return the path + pre-computed SHA-256 hash.
|
||||
/// Assemble chunks into final file and return the path + pre-computed BLAKE3 hash.
|
||||
///
|
||||
/// **Hash-on-Write**: SHA-256 is computed while copying chunks into the
|
||||
/// **Hash-on-Write**: BLAKE3 is computed while copying chunks into the
|
||||
/// assembled file, eliminating the second sequential read that dedup_service
|
||||
/// would otherwise need.
|
||||
///
|
||||
/// Returns `(assembled_file_path, filename, folder_id, content_type, total_size, sha256_hash)`.
|
||||
/// Returns `(assembled_file_path, filename, folder_id, content_type, total_size, blake3_hash)`.
|
||||
async fn complete_upload_inner(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
@@ -625,9 +625,9 @@ impl ChunkedUploadService {
|
||||
|
||||
// Assemble file with hash-on-write.
|
||||
//
|
||||
// The entire loop is offloaded to spawn_blocking because SHA-256
|
||||
// hashing is CPU-bound (~130 ms for 500 MB) and would otherwise
|
||||
// block a Tokio worker, starving all other connections.
|
||||
// The entire loop is offloaded to spawn_blocking because BLAKE3
|
||||
// hashing is CPU-bound and would otherwise block a Tokio worker,
|
||||
// starving all other connections.
|
||||
// Synchronous I/O is used inside the blocking thread — it avoids
|
||||
// the async reactor overhead and is actually faster for this
|
||||
// sequential workload.
|
||||
@@ -659,7 +659,7 @@ impl ChunkedUploadService {
|
||||
|
||||
// 512 KB I/O buffers — 8× fewer syscalls than 64 KB
|
||||
let mut output = StdBufWriter::with_capacity(524_288, raw_output);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
// Single 512 KB read buffer reused across all chunks (avoids N allocations)
|
||||
let mut buf = vec![0u8; 524_288];
|
||||
@@ -689,7 +689,7 @@ impl ChunkedUploadService {
|
||||
let _ = std::fs::remove_file(chunk_path);
|
||||
}
|
||||
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Assembly task panicked: {e}"))??;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Content-Addressable Storage with Deduplication (PostgreSQL-backed)
|
||||
//!
|
||||
//! Implements hash-based deduplication to eliminate redundant file storage.
|
||||
//! Files are stored by their SHA-256 hash, and multiple references can point
|
||||
//! Files are stored by their BLAKE3 hash, and multiple references can point
|
||||
//! to the same physical blob.
|
||||
//!
|
||||
//! Architecture:
|
||||
@@ -35,7 +35,7 @@ use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use futures::{Stream, TryStreamExt};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -49,7 +49,7 @@ use crate::application::ports::dedup_ports::{
|
||||
};
|
||||
use crate::domain::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Block size for SHA-256 file hashing (1MB — optimal syscall/throughput ratio).
|
||||
/// Block size for BLAKE3 file hashing (1MB — optimal syscall/throughput ratio).
|
||||
const HASH_BLOCK_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Chunk size for streaming file reads (256 KB)
|
||||
@@ -69,6 +69,27 @@ pub struct DedupService {
|
||||
maintenance_pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
/// Compile-time lookup table for the 256 two-digit lowercase hex prefixes ("00"…"ff").
|
||||
/// Avoids a `format!("{:02x}", i)` allocation on every iteration of `initialize()`.
|
||||
static HEX_PREFIXES: [&str; 256] = [
|
||||
"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", "0f",
|
||||
"10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d", "1e", "1f",
|
||||
"20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "2a", "2b", "2c", "2d", "2e", "2f",
|
||||
"30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b", "3c", "3d", "3e", "3f",
|
||||
"40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f",
|
||||
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "5a", "5b", "5c", "5d", "5e", "5f",
|
||||
"60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d", "6e", "6f",
|
||||
"70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "7a", "7b", "7c", "7d", "7e", "7f",
|
||||
"80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b", "8c", "8d", "8e", "8f",
|
||||
"90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f",
|
||||
"a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "aa", "ab", "ac", "ad", "ae", "af",
|
||||
"b0", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd", "be", "bf",
|
||||
"c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf",
|
||||
"d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db", "dc", "dd", "de", "df",
|
||||
"e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef",
|
||||
"f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "fa", "fb", "fc", "fd", "fe", "ff",
|
||||
];
|
||||
|
||||
impl DedupService {
|
||||
/// Create a new dedup service backed by PostgreSQL.
|
||||
///
|
||||
@@ -97,9 +118,8 @@ impl DedupService {
|
||||
.map_err(DomainError::from)?;
|
||||
|
||||
// Create hash prefix directories (00-ff)
|
||||
for i in 0..=255u8 {
|
||||
let prefix = format!("{:02x}", i);
|
||||
fs::create_dir_all(self.blob_root.join(&prefix))
|
||||
for prefix in &HEX_PREFIXES {
|
||||
fs::create_dir_all(self.blob_root.join(prefix))
|
||||
.await
|
||||
.map_err(DomainError::from)?;
|
||||
}
|
||||
@@ -135,25 +155,23 @@ impl DedupService {
|
||||
|
||||
// ── Hash helpers ─────────────────────────────────────────────
|
||||
|
||||
/// Calculate SHA-256 hash of content.
|
||||
/// Calculate BLAKE3 hash of content (~5× faster than SHA-256).
|
||||
pub fn hash_bytes(content: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content);
|
||||
hex::encode(hasher.finalize())
|
||||
blake3::hash(content).to_hex().to_string()
|
||||
}
|
||||
|
||||
/// Calculate SHA-256 hash of a file.
|
||||
/// Calculate BLAKE3 hash of a file (~5× faster than SHA-256).
|
||||
///
|
||||
/// Runs entirely on `spawn_blocking` with synchronous I/O so the Tokio
|
||||
/// worker threads are never blocked by CPU-bound hashing. Uses 1 MB
|
||||
/// reads for optimal syscall-to-throughput ratio (~3.8 GB/s on NVMe).
|
||||
/// reads for optimal syscall-to-throughput ratio.
|
||||
pub async fn hash_file(path: &Path) -> std::io::Result<String> {
|
||||
let path = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::io::Read;
|
||||
|
||||
let mut file = std::fs::File::open(&path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut buffer = vec![0u8; HASH_BLOCK_SIZE];
|
||||
|
||||
loop {
|
||||
@@ -164,7 +182,7 @@ impl DedupService {
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
})
|
||||
.await
|
||||
.expect("hash_file: spawn_blocking task panicked")
|
||||
@@ -212,15 +230,7 @@ impl DedupService {
|
||||
// same filesystem; if it fails because the other writer won,
|
||||
// we just discard our temp file — the blob is already there.
|
||||
if !blob_path.exists() {
|
||||
if let Some(parent) = blob_path.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"Dedup",
|
||||
format!("Failed to create blob directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Parent directory (xx/) guaranteed to exist — created by initialize()
|
||||
let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
fs::write(&temp_path, content).await.map_err(|e| {
|
||||
DomainError::internal_error("Dedup", format!("Failed to write temp blob: {}", e))
|
||||
@@ -278,7 +288,7 @@ impl DedupService {
|
||||
/// never held during disk I/O.
|
||||
///
|
||||
/// If `pre_computed_hash` is `Some`, the file will NOT be re-read for
|
||||
/// SHA-256 — saving one full sequential read (the biggest I/O win).
|
||||
/// BLAKE3 — saving one full sequential read (the biggest I/O win).
|
||||
pub async fn store_from_file(
|
||||
&self,
|
||||
source_path: &Path,
|
||||
@@ -310,14 +320,7 @@ impl DedupService {
|
||||
// Blob already on disk — discard the source file
|
||||
let _ = fs::remove_file(source_path).await;
|
||||
} else {
|
||||
if let Some(parent) = blob_path.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"Dedup",
|
||||
format!("Failed to create blob directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// Parent directory (xx/) guaranteed to exist — created by initialize()
|
||||
|
||||
// rename is atomic on the same filesystem. If source and blob
|
||||
// dirs live on different filesystems (rare), this falls back to
|
||||
@@ -632,7 +635,7 @@ impl DedupService {
|
||||
/// of `VERIFY_CONCURRENCY` using `buffer_unordered`.
|
||||
pub async fn verify_integrity(&self) -> Result<Vec<String>, DomainError> {
|
||||
/// Max blobs verified concurrently. Each spawns a blocking
|
||||
/// thread for SHA-256 so this also caps blocking-pool pressure.
|
||||
/// thread for BLAKE3 so this also caps blocking-pool pressure.
|
||||
const VERIFY_CONCURRENCY: usize = 16;
|
||||
|
||||
let mut row_stream = sqlx::query_as::<_, (String, i64)>(
|
||||
|
||||
@@ -26,16 +26,28 @@ use crate::domain::errors::{DomainError, ErrorKind};
|
||||
/// Maximum file size for transcoding (5MB - larger files stream directly)
|
||||
pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// Number of threads in the dedicated transcoding pool
|
||||
const TRANSCODE_POOL_THREADS: usize = 2;
|
||||
/// Minimum number of threads in the dedicated transcoding pool
|
||||
const MIN_TRANSCODE_THREADS: usize = 2;
|
||||
|
||||
/// Compute the number of transcoding threads: half the available CPUs,
|
||||
/// with a floor of `MIN_TRANSCODE_THREADS`. `available_parallelism()`
|
||||
/// respects cgroup limits (Docker/K8s) and CPU affinity masks.
|
||||
fn transcode_thread_count() -> usize {
|
||||
let cpus = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(MIN_TRANSCODE_THREADS);
|
||||
(cpus / 2).max(MIN_TRANSCODE_THREADS)
|
||||
}
|
||||
|
||||
/// Dedicated rayon thread pool for CPU-bound image transcoding.
|
||||
/// Isolated from Tokio's blocking pool to prevent starvation of other I/O.
|
||||
/// Thread count scales with available CPUs (half cores, min 2).
|
||||
fn transcode_pool() -> &'static rayon::ThreadPool {
|
||||
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
|
||||
POOL.get_or_init(|| {
|
||||
let threads = transcode_thread_count();
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(TRANSCODE_POOL_THREADS)
|
||||
.num_threads(threads)
|
||||
.thread_name(|idx| format!("transcode-{idx}"))
|
||||
.build()
|
||||
.expect("Failed to create transcode thread pool")
|
||||
@@ -171,7 +183,7 @@ impl ImageTranscodeService {
|
||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||
tracing::info!(
|
||||
"🖼️ Image transcode service initialized (rayon pool: {} threads, cache dir: {:?})",
|
||||
TRANSCODE_POOL_THREADS,
|
||||
transcode_thread_count(),
|
||||
self.cache_dir
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
//!
|
||||
//! This module provides JWT token generation and validation functionality,
|
||||
//! implementing the TokenServicePort trait defined in the application layer.
|
||||
//!
|
||||
//! **Performance optimisation**: a per-token validation cache (moka, lock-free)
|
||||
//! avoids repeating the HMAC-SHA256 verification on every request for the same
|
||||
//! token. Entries are keyed by a fast BLAKE3 hash of the raw token string and
|
||||
//! auto-expire after a short TTL (30 s by default) so revoked tokens don't stay
|
||||
//! valid for long.
|
||||
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use moka::sync::Cache;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::auth_ports::{TokenClaims, TokenServicePort};
|
||||
@@ -50,6 +59,24 @@ impl From<JwtClaims> for TokenClaims {
|
||||
///
|
||||
/// This service handles JWT token generation and validation for user authentication.
|
||||
/// It uses HS256 algorithm for signing tokens.
|
||||
///
|
||||
/// ## Validation cache
|
||||
///
|
||||
/// `jsonwebtoken::decode()` performs HMAC-SHA256 verification on every call.
|
||||
/// While fast in absolute terms (~2-4 µs on modern hardware), at 10 k req/s
|
||||
/// that is 20-40 ms of pure CPU per second — and it is synchronous, blocking
|
||||
/// the Tokio worker thread.
|
||||
///
|
||||
/// The cache uses the **BLAKE3** hash of the raw token string as key (32-byte,
|
||||
/// ~0.1 µs to compute — 20× cheaper than HMAC verification) and stores the
|
||||
/// validated `TokenClaims`. On a cache hit the HMAC step is completely
|
||||
/// skipped.
|
||||
///
|
||||
/// **Security properties**:
|
||||
/// - TTL of 30 s bounds the window in which a revoked token remains valid.
|
||||
/// - Max 50 000 entries (≈ 4 MB RSS) with LRU eviction prevents DoS via
|
||||
/// unique-token flooding.
|
||||
/// - Expired tokens are never cached (decode itself rejects them first).
|
||||
pub struct JwtTokenService {
|
||||
/// Secret key used for signing JWT tokens
|
||||
jwt_secret: String,
|
||||
@@ -57,8 +84,20 @@ pub struct JwtTokenService {
|
||||
access_token_expiry: i64,
|
||||
/// Expiration time for refresh tokens in seconds
|
||||
refresh_token_expiry: i64,
|
||||
/// Validation result cache: blake3(token) → TokenClaims
|
||||
validation_cache: Cache<[u8; 32], TokenClaims>,
|
||||
/// Cache hit counter (for observability / metrics)
|
||||
cache_hits: AtomicU64,
|
||||
/// Cache miss counter
|
||||
cache_misses: AtomicU64,
|
||||
}
|
||||
|
||||
/// Default TTL for cached validation results (seconds).
|
||||
const VALIDATION_CACHE_TTL_SECS: u64 = 30;
|
||||
|
||||
/// Maximum number of cached token validations.
|
||||
const VALIDATION_CACHE_MAX_ENTRIES: u64 = 50_000;
|
||||
|
||||
impl JwtTokenService {
|
||||
/// Create a new JwtTokenService with the specified configuration.
|
||||
///
|
||||
@@ -71,12 +110,43 @@ impl JwtTokenService {
|
||||
access_token_expiry_secs: i64,
|
||||
refresh_token_expiry_secs: i64,
|
||||
) -> Self {
|
||||
let validation_cache = Cache::builder()
|
||||
.max_capacity(VALIDATION_CACHE_MAX_ENTRIES)
|
||||
.time_to_live(Duration::from_secs(VALIDATION_CACHE_TTL_SECS))
|
||||
.build();
|
||||
|
||||
tracing::info!(
|
||||
"JWT validation cache initialised: TTL={}s, max_entries={}",
|
||||
VALIDATION_CACHE_TTL_SECS,
|
||||
VALIDATION_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
|
||||
Self {
|
||||
jwt_secret,
|
||||
access_token_expiry: access_token_expiry_secs,
|
||||
refresh_token_expiry: refresh_token_expiry_secs,
|
||||
validation_cache,
|
||||
cache_hits: AtomicU64::new(0),
|
||||
cache_misses: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fast BLAKE3 hash of a token string, used as cache key.
|
||||
///
|
||||
/// BLAKE3 is ~20× faster than SHA-256 and ~40× faster than HMAC-SHA256
|
||||
/// verification through `jsonwebtoken`, making it an ideal pre-filter.
|
||||
#[inline]
|
||||
fn token_hash(token: &str) -> [u8; 32] {
|
||||
blake3::hash(token.as_bytes()).into()
|
||||
}
|
||||
|
||||
/// Return cache hit/miss statistics for monitoring.
|
||||
pub fn cache_stats(&self) -> (u64, u64) {
|
||||
(
|
||||
self.cache_hits.load(Ordering::Relaxed),
|
||||
self.cache_misses.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenServicePort for JwtTokenService {
|
||||
@@ -125,6 +195,25 @@ impl TokenServicePort for JwtTokenService {
|
||||
}
|
||||
|
||||
fn validate_token(&self, token: &str) -> Result<TokenClaims, DomainError> {
|
||||
// ── 1. Fast-path: check the validation cache ─────────────
|
||||
let key = Self::token_hash(token);
|
||||
|
||||
if let Some(cached_claims) = self.validation_cache.get(&key) {
|
||||
// Even on a cache hit we must verify the token hasn't expired
|
||||
// since it was cached (the cached exp is an absolute timestamp).
|
||||
let now = Utc::now().timestamp();
|
||||
if cached_claims.exp > now {
|
||||
self.cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(cached_claims);
|
||||
}
|
||||
// Token expired while cached — evict and fall through to full
|
||||
// verification which will return the proper "Token expired" error.
|
||||
self.validation_cache.invalidate(&key);
|
||||
}
|
||||
|
||||
// ── 2. Slow-path: full HMAC-SHA256 verification ─────────
|
||||
self.cache_misses.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
let token_data = decode::<JwtClaims>(
|
||||
@@ -143,7 +232,17 @@ impl TokenServicePort for JwtTokenService {
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(token_data.claims.into())
|
||||
let claims: TokenClaims = token_data.claims.into();
|
||||
|
||||
// ── 3. Store in cache for subsequent requests ────────────
|
||||
// Only cache tokens that won't expire within the cache TTL window,
|
||||
// avoiding stale positives right at the boundary.
|
||||
let remaining_secs = claims.exp - Utc::now().timestamp();
|
||||
if remaining_secs > VALIDATION_CACHE_TTL_SECS as i64 {
|
||||
self.validation_cache.insert(key, claims.clone());
|
||||
}
|
||||
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
fn generate_refresh_token(&self) -> String {
|
||||
@@ -218,4 +317,43 @@ mod tests {
|
||||
let result = service.validate_token("invalid_token");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_cache_hit() {
|
||||
let service = JwtTokenService::new(
|
||||
"test_secret_key_at_least_32_bytes_long".to_string(),
|
||||
3600,
|
||||
86400,
|
||||
);
|
||||
|
||||
let user = create_test_user();
|
||||
let token = service
|
||||
.generate_access_token(&user)
|
||||
.expect("Should generate token");
|
||||
|
||||
// First call: cache miss — performs full HMAC verification
|
||||
let claims1 = service.validate_token(&token).expect("Should validate");
|
||||
|
||||
// Second call: cache hit — skips HMAC, returns cloned claims
|
||||
let claims2 = service.validate_token(&token).expect("Should validate from cache");
|
||||
|
||||
assert_eq!(claims1.sub, claims2.sub);
|
||||
assert_eq!(claims1.username, claims2.username);
|
||||
|
||||
let (hits, misses) = service.cache_stats();
|
||||
assert_eq!(hits, 1, "Expected 1 cache hit");
|
||||
assert_eq!(misses, 1, "Expected 1 cache miss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token_not_cached() {
|
||||
let service = JwtTokenService::new("secret".to_string(), 3600, 86400);
|
||||
|
||||
// Invalid tokens should never be cached
|
||||
let _ = service.validate_token("bad_token");
|
||||
let _ = service.validate_token("bad_token");
|
||||
|
||||
let (hits, _misses) = service.cache_stats();
|
||||
assert_eq!(hits, 0, "Invalid tokens should never produce cache hits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! HTTP handlers for App Password management.
|
||||
//!
|
||||
//! All endpoints require JWT authentication (the user must be logged in to
|
||||
//! create/list/revoke their app passwords).
|
||||
|
||||
use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use axum::extract::State;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Protected routes — require JWT auth middleware.
|
||||
pub fn app_password_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/app-passwords", post(create_app_password))
|
||||
.route("/app-passwords", get(list_app_passwords))
|
||||
.route("/app-passwords/{id}", delete(revoke_app_password))
|
||||
}
|
||||
|
||||
/// POST /api/auth/app-passwords — Create a new app password.
|
||||
///
|
||||
/// Returns the plain-text password ONCE. The user must copy it immediately.
|
||||
async fn create_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(user): axum::Extension<CurrentUser>,
|
||||
Json(request): Json<CreateAppPasswordRequestDto>,
|
||||
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError>
|
||||
{
|
||||
let service = state
|
||||
.app_password_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("App password service not configured"))?;
|
||||
|
||||
let response = service
|
||||
.create(&user.id, request)
|
||||
.await
|
||||
.map_err(|e| AppError::from(e))?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// GET /api/auth/app-passwords — List all app passwords for the current user.
|
||||
///
|
||||
/// Never returns plain-text passwords (only prefix + metadata).
|
||||
async fn list_app_passwords(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(user): axum::Extension<CurrentUser>,
|
||||
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordListResponseDto>, AppError>
|
||||
{
|
||||
let service = state
|
||||
.app_password_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("App password service not configured"))?;
|
||||
|
||||
let response = service
|
||||
.list(&user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::from(e))?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// DELETE /api/auth/app-passwords/:id — Revoke an app password.
|
||||
async fn revoke_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(user): axum::Extension<CurrentUser>,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordRevokeResponseDto>, AppError>
|
||||
{
|
||||
let service = state
|
||||
.app_password_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("App password service not configured"))?;
|
||||
|
||||
let response = service
|
||||
.revoke(&user.id, &id)
|
||||
.await
|
||||
.map_err(|e| AppError::from(e))?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use bytes::Buf;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
|
||||
@@ -86,19 +87,21 @@ async fn handle_caldav_methods_inner(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the CalDAV path from the full URI path.
|
||||
/// Extract the CalDAV path from the full URI path, percent-decoding the result.
|
||||
fn extract_caldav_path(uri_path: &str) -> String {
|
||||
if let Some(pos) = uri_path.find("/caldav/") {
|
||||
let encoded = if let Some(pos) = uri_path.find("/caldav/") {
|
||||
let after = &uri_path[pos + 8..];
|
||||
after.trim_end_matches('/').to_string()
|
||||
after.trim_end_matches('/')
|
||||
} else if uri_path.ends_with("/caldav") {
|
||||
String::new()
|
||||
""
|
||||
} else {
|
||||
uri_path
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
// ─── Helper: extract user from request ───────────────────────────────
|
||||
|
||||
@@ -91,19 +91,21 @@ async fn handle_carddav_methods_inner(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the CardDAV path from the full URI path.
|
||||
/// Extract the CardDAV path from the full URI path, percent-decoding the result.
|
||||
fn extract_carddav_path(uri_path: &str) -> String {
|
||||
if let Some(pos) = uri_path.find("/carddav/") {
|
||||
let encoded = if let Some(pos) = uri_path.find("/carddav/") {
|
||||
let after = &uri_path[pos + 9..];
|
||||
after.trim_end_matches('/').to_string()
|
||||
after.trim_end_matches('/')
|
||||
} else if uri_path.ends_with("/carddav") {
|
||||
String::new()
|
||||
""
|
||||
} else {
|
||||
uri_path
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
percent_encoding::percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
// ─── Helper: extract user from request ───────────────────────────────
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! POST /api/auth/device/authorize — Client starts the device flow (public)
|
||||
//! GET /api/auth/device/verify — Check user_code validity (authenticated)
|
||||
//! POST /api/auth/device/verify — User approves/denies (authenticated)
|
||||
//! POST /api/auth/device/token — Client polls for tokens (public)
|
||||
//! GET /api/auth/device/devices — List user's authorized devices (authenticated)
|
||||
//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated)
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Create the device auth router.
|
||||
///
|
||||
/// Public endpoints (no auth middleware): authorize, token
|
||||
/// Protected endpoints (behind auth middleware): verify (GET+POST), devices
|
||||
pub fn device_auth_public_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Client-facing endpoints (no auth needed — the client doesn't have tokens yet)
|
||||
.route("/authorize", post(device_authorize))
|
||||
.route("/token", post(device_token))
|
||||
}
|
||||
|
||||
pub fn device_auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// User-facing endpoints (require valid session)
|
||||
.route("/verify", get(device_verify_info))
|
||||
.route("/verify", post(device_verify_action))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/{id}", delete(revoke_device))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/authorize — Client initiates the device flow
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }`
|
||||
/// Server returns: device_code, user_code, verification_uri, etc.
|
||||
async fn device_authorize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceAuthorizeRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let response = device_service.initiate(body).await.map_err(|e| {
|
||||
tracing::error!("Device authorize failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/token — Client polls for tokens
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }`
|
||||
/// Returns tokens on success, or RFC 8628 error codes while pending.
|
||||
async fn device_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceTokenRequestDto>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let device_service = match get_device_service(&state) {
|
||||
Ok(svc) => svc,
|
||||
Err(e) => return Err(e.into_response()),
|
||||
};
|
||||
|
||||
// Validate grant_type if provided (RFC compliance)
|
||||
if !body.grant_type.is_empty()
|
||||
&& body.grant_type != "urn:ietf:params:oauth:grant-type:device_code"
|
||||
{
|
||||
let error_body = serde_json::json!({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code"
|
||||
});
|
||||
return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response());
|
||||
}
|
||||
|
||||
match device_service.poll(&body.device_code).await {
|
||||
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
||||
Err(poll_err) => {
|
||||
let status = StatusCode::from_u16(poll_err.http_status())
|
||||
.unwrap_or(StatusCode::BAD_REQUEST);
|
||||
let error_body = serde_json::json!({
|
||||
"error": poll_err.error_code(),
|
||||
"error_description": poll_err.description()
|
||||
});
|
||||
Err((status, Json(error_body)).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct VerifyQuery {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
async fn device_verify_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth_user: AuthUser,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let info = device_service
|
||||
.verify_user_code(&query.code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Device verify lookup failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(info)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/verify — User approves or denies
|
||||
// ============================================================================
|
||||
|
||||
async fn device_verify_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<DeviceVerifyRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
match body.action.to_lowercase().as_str() {
|
||||
"approve" | "allow" | "accept" => {
|
||||
device_service
|
||||
.approve(&body.user_code, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Device approve failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "approved" })),
|
||||
))
|
||||
}
|
||||
"deny" | "reject" | "cancel" => {
|
||||
device_service.deny(&body.user_code).await.map_err(|e| {
|
||||
tracing::error!("Device deny failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "denied" })),
|
||||
))
|
||||
}
|
||||
_ => Err(AppError::bad_request(
|
||||
"action must be 'approve' or 'deny'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/devices — List user's authorized devices
|
||||
// ============================================================================
|
||||
|
||||
async fn list_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let devices = device_service
|
||||
.list_user_devices(&auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("List devices failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(devices)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /api/auth/device/devices/{id} — Revoke a device authorization
|
||||
// ============================================================================
|
||||
|
||||
async fn revoke_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
device_service
|
||||
.revoke_device(&device_id, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Revoke device failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper
|
||||
// ============================================================================
|
||||
|
||||
fn get_device_service(state: &AppState) -> Result<&Arc<DeviceAuthService>, AppError> {
|
||||
state
|
||||
.device_auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
|
||||
}
|
||||
@@ -37,7 +37,7 @@ impl FileHandler {
|
||||
|
||||
/// Streaming file upload — constant ~64 KB RAM regardless of file size.
|
||||
///
|
||||
/// **Hash-on-Write**: SHA-256 is computed while spooling the multipart
|
||||
/// **Hash-on-Write**: BLAKE3 is computed while spooling the multipart
|
||||
/// body to the temp file. This eliminates the second sequential read
|
||||
/// that dedup_service would otherwise need, cutting total I/O in half.
|
||||
pub async fn upload_file(
|
||||
@@ -61,8 +61,6 @@ impl FileHandler {
|
||||
auth_user: &AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<crate::application::dtos::file_dto::FileDto, Response<Body>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let upload_service = &state.applications.file_upload_service;
|
||||
let mut folder_id: Option<String> = None;
|
||||
|
||||
@@ -121,12 +119,12 @@ impl FileHandler {
|
||||
}
|
||||
|
||||
// ── Spool multipart field to temp file + hash-on-write ──
|
||||
// .dedup_temp is created once by DedupService::initialize() at startup
|
||||
let temp_dir = state.core.path_service.get_root_path().join(".dedup_temp");
|
||||
let _ = tokio::fs::create_dir_all(&temp_dir).await;
|
||||
let temp_path = temp_dir.join(format!("upload-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let spool_result: Result<(), String> = async {
|
||||
let file = tokio::fs::File::create(&temp_path)
|
||||
.await
|
||||
@@ -169,7 +167,7 @@ impl FileHandler {
|
||||
|
||||
// Empty file — use streaming path with the (empty) temp file
|
||||
if total_size == 0 {
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
let hash = hasher.finalize().to_hex().to_string();
|
||||
return upload_service
|
||||
.upload_file_streaming(
|
||||
filename,
|
||||
@@ -184,7 +182,7 @@ impl FileHandler {
|
||||
}
|
||||
|
||||
// Finalize hash
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
let hash = hasher.finalize().to_hex().to_string();
|
||||
|
||||
// ── MIME detection (magic bytes + extension fallback) ─
|
||||
let content_type = crate::common::mime_detect::refine_content_type_from_file(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod admin_handler;
|
||||
pub mod app_password_handler;
|
||||
pub mod auth_handler;
|
||||
pub mod batch_handler;
|
||||
pub mod device_auth_handler;
|
||||
pub mod caldav_handler;
|
||||
pub mod carddav_handler;
|
||||
pub mod chunked_upload_handler;
|
||||
|
||||
@@ -27,8 +27,45 @@ use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
|
||||
/// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
|
||||
/// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
|
||||
/// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
|
||||
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'_')
|
||||
.remove(b'~')
|
||||
.remove(b'!')
|
||||
.remove(b'$')
|
||||
.remove(b'&')
|
||||
.remove(b'\'')
|
||||
.remove(b'(')
|
||||
.remove(b')')
|
||||
.remove(b'*')
|
||||
.remove(b'+')
|
||||
.remove(b',')
|
||||
.remove(b';')
|
||||
.remove(b'=')
|
||||
.remove(b':')
|
||||
.remove(b'@');
|
||||
|
||||
/// Percent-encode a single URI path segment (folder/file name).
|
||||
fn encode_path_segment(segment: &str) -> String {
|
||||
utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode a full slash-separated path, encoding each segment individually.
|
||||
pub(crate) fn encode_uri_path(path: &str) -> String {
|
||||
path.split('/')
|
||||
.map(|seg| encode_path_segment(seg))
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
// Create a custom DAV header since it's not in the standard headers
|
||||
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||||
const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token");
|
||||
@@ -62,22 +99,24 @@ pub fn webdav_routes() -> Router<Arc<AppState>> {
|
||||
.route("/webdav", axum::routing::any(handle_webdav_methods_root))
|
||||
}
|
||||
|
||||
/// Extract the resource path from the request URI, stripping the `/webdav/` prefix.
|
||||
/// Extract the resource path from the request URI, stripping the `/webdav/` prefix
|
||||
/// and percent-decoding the result so that folder/file names with spaces and
|
||||
/// special characters match the values stored in the database.
|
||||
fn extract_webdav_path(uri: &axum::http::Uri) -> String {
|
||||
let raw = uri.path();
|
||||
if let Some(rest) = raw.strip_prefix("/webdav/") {
|
||||
rest.trim_end_matches('/').to_string()
|
||||
let encoded = if let Some(rest) = raw.strip_prefix("/webdav/") {
|
||||
rest.trim_end_matches('/')
|
||||
} else if raw == "/webdav" {
|
||||
String::new()
|
||||
""
|
||||
} else {
|
||||
// Fallback: split-based extraction
|
||||
let parts: Vec<&str> = raw.split('/').collect();
|
||||
if parts.len() > 2 {
|
||||
parts[2..].join("/")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
let trimmed = raw.strip_prefix('/').unwrap_or(raw);
|
||||
trimmed.trim_end_matches('/')
|
||||
};
|
||||
// Decode percent-encoded characters (e.g. %20 → space)
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
async fn handle_webdav_methods_root(
|
||||
@@ -230,7 +269,7 @@ async fn handle_propfind(
|
||||
let base_href = if path.is_empty() || path == "/" {
|
||||
"/webdav/".to_string()
|
||||
} else {
|
||||
format!("/webdav/{}/", path)
|
||||
format!("/webdav/{}/", encode_uri_path(&path))
|
||||
};
|
||||
|
||||
// ── 5. Determine target resource ─────────────────────────────
|
||||
@@ -358,7 +397,7 @@ async fn build_streaming_propfind_response(
|
||||
{
|
||||
let mut w = Writer::new(&mut chunk);
|
||||
for subfolder in &result.items {
|
||||
let href = format!("{}{}/", base_href, subfolder.name);
|
||||
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
|
||||
WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
@@ -389,7 +428,7 @@ async fn build_streaming_propfind_response(
|
||||
{
|
||||
let mut w = Writer::new(&mut chunk);
|
||||
for file in &batch {
|
||||
let href = format!("{}{}", base_href, file.name);
|
||||
let href = format!("{}{}", base_href, encode_path_segment(&file.name));
|
||||
WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
@@ -472,7 +511,7 @@ async fn handle_proppatch(
|
||||
}
|
||||
|
||||
// Generate response
|
||||
let href = format!("/webdav/{}", path);
|
||||
let href = format!("/webdav/{}", encode_uri_path(&path));
|
||||
let mut response_body = Vec::new();
|
||||
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
||||
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
||||
@@ -593,7 +632,7 @@ async fn handle_head(
|
||||
* Handles PUT requests to create or update files.
|
||||
*
|
||||
* **Streaming implementation**: the request body is spooled to a temp file
|
||||
* with incremental SHA-256 hashing. Peak RAM usage is ~256 KB regardless
|
||||
* with incremental BLAKE3 hashing. Peak RAM usage is ~256 KB regardless
|
||||
* of file size. The temp file is then atomically moved into blob storage
|
||||
* via `update_file_streaming`.
|
||||
*
|
||||
@@ -608,7 +647,6 @@ async fn handle_put(
|
||||
path: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
use http_body_util::BodyStream;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
@@ -640,7 +678,7 @@ async fn handle_put(
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to open temp file: {}", e)))?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut total_bytes: usize = 0;
|
||||
let mut stream = BodyStream::new(req.into_body());
|
||||
|
||||
@@ -669,7 +707,7 @@ async fn handle_put(
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to flush temp file: {}", e)))?;
|
||||
drop(file);
|
||||
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
let hash = hasher.finalize().to_hex().to_string();
|
||||
|
||||
// ── Atomic store: temp file → dedup blob + DB metadata update ──
|
||||
let result = file_upload_service
|
||||
@@ -860,10 +898,11 @@ async fn handle_move(
|
||||
.unwrap_or("T")
|
||||
!= "F";
|
||||
|
||||
// Extract destination path from URL
|
||||
// Extract destination path from URL and percent-decode it
|
||||
let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
|
||||
let after_prefix = &destination[webdav_prefix + 8..];
|
||||
after_prefix.trim_end_matches('/').to_string()
|
||||
let trimmed = after_prefix.trim_end_matches('/');
|
||||
percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
|
||||
} else {
|
||||
return Err(AppError::bad_request("Invalid destination URL"));
|
||||
};
|
||||
@@ -1021,10 +1060,11 @@ async fn handle_copy(
|
||||
.unwrap_or("T")
|
||||
!= "F";
|
||||
|
||||
// Extract destination path from URL
|
||||
// Extract destination path from URL and percent-decode it
|
||||
let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
|
||||
let after_prefix = &destination[webdav_prefix + 8..];
|
||||
after_prefix.trim_end_matches('/').to_string()
|
||||
let trimmed = after_prefix.trim_end_matches('/');
|
||||
percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
|
||||
} else {
|
||||
return Err(AppError::bad_request("Invalid destination URL"));
|
||||
};
|
||||
@@ -1229,7 +1269,7 @@ async fn handle_lock(
|
||||
};
|
||||
|
||||
// Generate response
|
||||
let href = format!("/webdav/{}", path);
|
||||
let href = format!("/webdav/{}", encode_uri_path(&path));
|
||||
let mut response_body = Vec::new();
|
||||
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|
||||
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
|
||||
@@ -1258,7 +1298,7 @@ async fn handle_lock(
|
||||
};
|
||||
|
||||
// Generate response
|
||||
let href = format!("/webdav/{}", path);
|
||||
let href = format!("/webdav/{}", encode_uri_path(&path));
|
||||
let mut response_body = Vec::new();
|
||||
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|
||||
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
|
||||
|
||||
@@ -155,7 +155,7 @@ async fn get_file(
|
||||
/// POST /wopi/files/{file_id}/contents — PutFile
|
||||
///
|
||||
/// **Streaming implementation**: the request body is spooled to a temp file
|
||||
/// with incremental SHA-256 hashing. Peak RAM usage is ~256 KB regardless
|
||||
/// with incremental BLAKE3 hashing. Peak RAM usage is ~256 KB regardless
|
||||
/// of file size (previously buffered the entire body as `Bytes`).
|
||||
async fn put_file(
|
||||
Path(file_id): Path<String>,
|
||||
@@ -165,7 +165,6 @@ async fn put_file(
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
use http_body_util::BodyStream;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
@@ -218,7 +217,7 @@ async fn put_file(
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
// ── Streaming spool: body → temp file + incremental SHA-256 ──
|
||||
// ── Streaming spool: body → temp file + incremental BLAKE3 ──
|
||||
let temp_file = match tempfile::NamedTempFile::new() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
@@ -237,7 +236,7 @@ async fn put_file(
|
||||
};
|
||||
|
||||
let content_type = file.mime_type.clone();
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut total_bytes: u64 = 0;
|
||||
let mut stream = BodyStream::new(req.into_body());
|
||||
|
||||
@@ -267,7 +266,7 @@ async fn put_file(
|
||||
}
|
||||
drop(file_out);
|
||||
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
let hash = hasher.finalize().to_hex().to_string();
|
||||
|
||||
// ── Atomic store: temp file → dedup blob + DB metadata update ──
|
||||
let result = state
|
||||
|
||||
@@ -154,54 +154,119 @@ impl IntoResponse for AuthError {
|
||||
|
||||
/// Secure authentication middleware.
|
||||
///
|
||||
/// Validates the JWT token against the configured authentication service.
|
||||
/// Does not accept bypasses, mock tokens, or URL parameters to skip validation.
|
||||
/// Supports two authentication methods:
|
||||
/// 1. **Bearer JWT** — standard token in `Authorization: Bearer <token>`
|
||||
/// 2. **Basic Auth with App Passwords** — for DAV clients (DAVx⁵, Thunderbird, rclone)
|
||||
/// that send `Authorization: Basic base64(username:app_password)`
|
||||
///
|
||||
/// Bearer is tried first; if no Bearer header is found, Basic is attempted.
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AuthError> {
|
||||
// Extract the Bearer token from the Authorization header
|
||||
let token_str = headers
|
||||
let auth_header = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or(AuthError::TokenNotProvided)?;
|
||||
.and_then(|value| value.to_str().ok());
|
||||
|
||||
// Validate that the token is not empty
|
||||
let token_str = token_str.trim();
|
||||
if token_str.is_empty() {
|
||||
return Err(AuthError::TokenNotProvided);
|
||||
}
|
||||
// ── 1. Try Bearer JWT ────────────────────────────────────────
|
||||
if let Some(header_value) = auth_header {
|
||||
if let Some(token_str) = header_value.strip_prefix("Bearer ") {
|
||||
let token_str = token_str.trim();
|
||||
if !token_str.is_empty() {
|
||||
tracing::debug!("Processing Bearer authentication token");
|
||||
|
||||
tracing::debug!("Processing authentication token");
|
||||
|
||||
// Validate the token using the authentication service
|
||||
if let Some(auth_service) = state.auth_service.as_ref() {
|
||||
let token_service = &auth_service.token_service;
|
||||
match token_service.validate_token(token_str) {
|
||||
Ok(claims) => {
|
||||
tracing::debug!("Token validated successfully for user: {}", claims.username);
|
||||
let current_user = CurrentUser {
|
||||
id: claims.sub,
|
||||
username: claims.username,
|
||||
email: claims.email,
|
||||
role: claims.role,
|
||||
};
|
||||
request.extensions_mut().insert(current_user);
|
||||
return Ok(next.run(request).await);
|
||||
if let Some(auth_service) = state.auth_service.as_ref() {
|
||||
let token_service = &auth_service.token_service;
|
||||
match token_service.validate_token(token_str) {
|
||||
Ok(claims) => {
|
||||
tracing::debug!(
|
||||
"Token validated successfully for user: {}",
|
||||
claims.username
|
||||
);
|
||||
let current_user = CurrentUser {
|
||||
id: claims.sub,
|
||||
username: claims.username,
|
||||
email: claims.email,
|
||||
role: claims.role,
|
||||
};
|
||||
request.extensions_mut().insert(current_user);
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Bearer token validation failed: {}", e);
|
||||
return Err(AuthError::InvalidToken(format!(
|
||||
"Invalid token: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Token validation failed: {}", e);
|
||||
return Err(AuthError::InvalidToken(format!("Invalid token: {}", e)));
|
||||
}
|
||||
|
||||
// ── 2. Try Basic Auth with App Passwords ─────────────────
|
||||
if let Some(basic_encoded) = header_value.strip_prefix("Basic ") {
|
||||
let basic_encoded = basic_encoded.trim();
|
||||
if !basic_encoded.is_empty() {
|
||||
tracing::debug!("Processing Basic authentication (app password)");
|
||||
|
||||
// Decode base64(username:password)
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(basic_encoded)
|
||||
.map_err(|_| {
|
||||
AuthError::InvalidToken("Invalid Basic auth encoding".to_string())
|
||||
})?;
|
||||
let credentials = String::from_utf8(decoded).map_err(|_| {
|
||||
AuthError::InvalidToken("Invalid Basic auth encoding".to_string())
|
||||
})?;
|
||||
|
||||
let (username, password) = credentials.split_once(':').ok_or_else(|| {
|
||||
AuthError::InvalidToken("Invalid Basic auth format".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(app_pw_service) = state.app_password_service.as_ref() {
|
||||
match app_pw_service.verify_basic_auth(username, password).await {
|
||||
Ok((user_id, uname, email, role)) => {
|
||||
tracing::debug!(
|
||||
"App password authentication successful for user: {}",
|
||||
uname
|
||||
);
|
||||
let current_user = CurrentUser {
|
||||
id: user_id,
|
||||
username: uname,
|
||||
email,
|
||||
role,
|
||||
};
|
||||
request.extensions_mut().insert(current_user);
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("App password verification failed: {}", e);
|
||||
return Err(AuthError::InvalidToken(
|
||||
"Invalid username or app password".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Basic auth attempted but app password service not configured");
|
||||
return Err(AuthError::InvalidToken(
|
||||
"App passwords are not enabled".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no authentication service is available, deny access
|
||||
tracing::error!("Auth middleware invoked but auth service is not configured");
|
||||
Err(AuthError::AuthServiceUnavailable)
|
||||
// No valid Authorization header found
|
||||
if state.auth_service.is_none() {
|
||||
tracing::error!("Auth middleware invoked but auth service is not configured");
|
||||
return Err(AuthError::AuthServiceUnavailable);
|
||||
}
|
||||
|
||||
Err(AuthError::TokenNotProvided)
|
||||
}
|
||||
|
||||
/// Middleware to verify that the authenticated user has an admin role.
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
|
||||
.route("/login", get(serve_login_page))
|
||||
.route("/profile", get(serve_profile_page))
|
||||
.route("/admin", get(serve_admin_page))
|
||||
.route("/device", get(serve_device_verify_page))
|
||||
// Serve static files with compression + cache headers
|
||||
.fallback_service(static_service)
|
||||
.layer(CompressionLayer::new().br(true).gzip(true))
|
||||
@@ -47,3 +48,8 @@ async fn serve_profile_page() -> Html<&'static str> {
|
||||
async fn serve_admin_page() -> Html<&'static str> {
|
||||
Html(include_str!("../../../static/admin.html"))
|
||||
}
|
||||
|
||||
/// Serve the device verification page (RFC 8628 Device Authorization Grant)
|
||||
async fn serve_device_verify_page() -> Html<&'static str> {
|
||||
Html(include_str!("../../../static/device-verify.html"))
|
||||
}
|
||||
|
||||
+58
-2
@@ -1,6 +1,12 @@
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
@@ -163,10 +169,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
if config.features.enable_auth {
|
||||
use interfaces::api::handlers::auth_handler::auth_routes;
|
||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||
|
||||
let auth_router = auth_routes().with_state(app_state.clone());
|
||||
|
||||
// Device Authorization Grant (RFC 8628)
|
||||
// Public endpoints: /api/auth/device/authorize + /api/auth/device/token
|
||||
let device_public = device_auth_handler::device_auth_public_routes()
|
||||
.with_state(app_state.clone());
|
||||
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
||||
let device_protected = device_auth_handler::device_auth_protected_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
// App Password management endpoints (protected — require JWT)
|
||||
let app_password_protected = app_password_handler::app_password_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
// Protected API routes — require valid JWT token
|
||||
let protected_api = api_routes.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -190,6 +218,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
app = Router::new()
|
||||
// Auth endpoints (login, register, refresh) are public — no middleware
|
||||
.nest("/api/auth", auth_router)
|
||||
// Device Auth Grant public endpoints (authorize + token polling)
|
||||
.nest("/api/auth/device", device_public)
|
||||
// Device Auth Grant protected endpoints (verify + device management)
|
||||
.nest("/api/auth/device", device_protected)
|
||||
// App Password management endpoints (create, list, revoke)
|
||||
.nest("/api/auth", app_password_protected)
|
||||
// Public API routes (share access, i18n) — no auth required
|
||||
.nest("/api", public_api_routes)
|
||||
// All other API routes are protected by auth middleware
|
||||
@@ -238,15 +272,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Without this Axum caps Multipart bodies at 2 MB.
|
||||
app = app.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024));
|
||||
|
||||
// Start server
|
||||
// Start server — tuned socket for low-latency responses
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 8086));
|
||||
tracing::info!("Starting OxiCloud server on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
// Allow multiple workers on the same port (future-ready)
|
||||
#[cfg(not(windows))]
|
||||
socket.set_reuse_port(true)?;
|
||||
// Disable Nagle's algorithm — send small responses (JSON, PROPFIND)
|
||||
// immediately instead of waiting up to 40ms for coalescing.
|
||||
socket.set_tcp_nodelay(true)?;
|
||||
// Detect dead connections within 60s instead of hours
|
||||
socket.set_keepalive(true)?;
|
||||
socket.set_tcp_keepalive(
|
||||
&TcpKeepalive::new()
|
||||
.with_time(Duration::from_secs(60))
|
||||
.with_interval(Duration::from_secs(10)),
|
||||
)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
// High backlog for connection bursts (WebDAV clients open many parallel connections)
|
||||
socket.listen(2048)?;
|
||||
|
||||
let listener = tokio::net::TcpListener::from_std(socket.into())?;
|
||||
|
||||
// Provide the fully-built state to the router
|
||||
let app = app.with_state(app_state);
|
||||
|
||||
// TCP_NODELAY is inherited from the listening socket on Linux,
|
||||
// so every accepted connection already has Nagle disabled.
|
||||
axum::serve(listener, app).await?;
|
||||
tracing::info!("Server shutdown completed");
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OxiCloud — Authorize Device</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #b91c1c;
|
||||
--success: #16a34a;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||||
padding: 2.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
}
|
||||
.logo { text-align: center; margin-bottom: 1.5rem; }
|
||||
.logo h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
.logo span { color: var(--primary); }
|
||||
h2 { font-size: 1.15rem; margin-bottom: 0.5rem; }
|
||||
p.subtitle { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 0.4rem; }
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type="text"]:focus { border-color: var(--primary); }
|
||||
.device-info {
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
display: none;
|
||||
}
|
||||
.device-info .row { display: flex; justify-content: space-between; margin-bottom: 0.3rem; }
|
||||
.device-info .label { color: var(--muted); font-size: 0.85rem; }
|
||||
.device-info .value { font-weight: 600; font-size: 0.85rem; }
|
||||
.actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-approve { background: var(--primary); color: #fff; }
|
||||
.btn-approve:hover { background: var(--primary-hover); }
|
||||
.btn-deny { background: var(--danger); color: #fff; }
|
||||
.btn-deny:hover { background: var(--danger-hover); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
}
|
||||
.status.success { display: block; background: #dcfce7; color: var(--success); }
|
||||
.status.denied { display: block; background: #fef2f2; color: var(--danger); }
|
||||
.status.error { display: block; background: #fef2f2; color: var(--danger); }
|
||||
.error-text { color: var(--danger); font-size: 0.85rem; margin-top: 0.5rem; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1><span>Oxi</span>Cloud</h1>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Enter code -->
|
||||
<div id="step-code">
|
||||
<h2>Authorize Device</h2>
|
||||
<p class="subtitle">Enter the code displayed on your WebDAV/CalDAV client to grant access.</p>
|
||||
<label for="user-code">Device Code</label>
|
||||
<input type="text" id="user-code" placeholder="ABCD-1234" maxlength="9" autocomplete="off" autofocus />
|
||||
<div id="error-text" class="error-text"></div>
|
||||
|
||||
<div id="device-info" class="device-info">
|
||||
<div class="row">
|
||||
<span class="label">Client</span>
|
||||
<span class="value" id="info-client">—</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Scopes</span>
|
||||
<span class="value" id="info-scopes">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="action-buttons" style="display:none;">
|
||||
<button class="btn-deny" id="btn-deny" onclick="handleAction('deny')">Deny</button>
|
||||
<button class="btn-approve" id="btn-approve" onclick="handleAction('approve')">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Result -->
|
||||
<div id="status-success" class="status success">
|
||||
Device authorized successfully! You can close this page.
|
||||
</div>
|
||||
<div id="status-denied" class="status denied">
|
||||
Authorization denied. The client will not receive access.
|
||||
</div>
|
||||
<div id="status-error" class="status error" id="status-error-msg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const codeInput = document.getElementById('user-code');
|
||||
const deviceInfo = document.getElementById('device-info');
|
||||
const actionButtons = document.getElementById('action-buttons');
|
||||
const errorText = document.getElementById('error-text');
|
||||
let debounceTimer = null;
|
||||
let currentCode = '';
|
||||
|
||||
// Pre-fill from URL query param (?code=ABCD-1234)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('code')) {
|
||||
codeInput.value = params.get('code');
|
||||
lookupCode(params.get('code'));
|
||||
}
|
||||
|
||||
// Auto-insert hyphen and lookup on input
|
||||
codeInput.addEventListener('input', (e) => {
|
||||
let val = e.target.value.toUpperCase().replace(/[^A-Z0-9\-]/g, '');
|
||||
// Auto-insert hyphen after 4 chars
|
||||
if (val.length === 4 && !val.includes('-')) {
|
||||
val = val + '-';
|
||||
}
|
||||
e.target.value = val;
|
||||
errorText.style.display = 'none';
|
||||
|
||||
// Debounce lookup
|
||||
clearTimeout(debounceTimer);
|
||||
if (val.length >= 9) {
|
||||
debounceTimer = setTimeout(() => lookupCode(val), 300);
|
||||
} else {
|
||||
deviceInfo.style.display = 'none';
|
||||
actionButtons.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
async function lookupCode(code) {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
showError('You must be logged in to authorize a device. Please log in first.');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!resp.ok) throw new Error('Lookup failed');
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.valid) {
|
||||
currentCode = code;
|
||||
document.getElementById('info-client').textContent = data.client_name || 'Unknown';
|
||||
document.getElementById('info-scopes').textContent = data.scopes || 'all';
|
||||
deviceInfo.style.display = 'block';
|
||||
actionButtons.style.display = 'flex';
|
||||
errorText.style.display = 'none';
|
||||
} else {
|
||||
deviceInfo.style.display = 'none';
|
||||
actionButtons.style.display = 'none';
|
||||
showError('Code not found or expired. Please check and try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Failed to verify code. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
const btnApprove = document.getElementById('btn-approve');
|
||||
const btnDeny = document.getElementById('btn-deny');
|
||||
btnApprove.disabled = true;
|
||||
btnDeny.disabled = true;
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const resp = await fetch(`${API_BASE}/api/auth/device/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ user_code: currentCode, action: action })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || 'Action failed');
|
||||
}
|
||||
|
||||
document.getElementById('step-code').style.display = 'none';
|
||||
if (action === 'approve') {
|
||||
document.getElementById('status-success').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('status-denied').style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
btnApprove.disabled = false;
|
||||
btnDeny.disabled = false;
|
||||
showError(err.message || 'Failed to process action.');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorText.textContent = msg;
|
||||
errorText.style.display = 'block';
|
||||
}
|
||||
|
||||
function getAuthToken() {
|
||||
// Try localStorage (OxiCloud frontend stores tokens there)
|
||||
try {
|
||||
const stored = localStorage.getItem('auth_token')
|
||||
|| localStorage.getItem('access_token')
|
||||
|| localStorage.getItem('oxicloud_token');
|
||||
if (stored) return stored;
|
||||
// Try parsing a JSON auth object
|
||||
const authData = localStorage.getItem('auth');
|
||||
if (authData) {
|
||||
const parsed = JSON.parse(authData);
|
||||
return parsed.access_token || parsed.token;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user