From 3c316955797a39f984b9914fea71e7abb0350de4 Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Wed, 24 Jun 2026 23:52:01 -0600 Subject: [PATCH 1/8] =?UTF-8?q?feat(mounts):=20external=20file=20mounts=20?= =?UTF-8?q?P1=20=E2=80=94=20pluggable=20provider=20+=20read-only=20REST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundation for external file mounts: admin-configured backends (raw host filesystem in v1; sftp/webdav/… as future provider kinds) surfaced as a folder inside a user's drive. Mount contents are virtual/live-passthrough — read straight from the backend, never stored in storage.files — and are a deliberately separate, limited storage type (no dedup/sharing/trash/search). The feature is dark by default (OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false). P1 scope (this PR): data model, the pluggable provider abstraction, and the read-only REST surface (mount listing + download). Read-write (P2), WebDAV/NextCloud path resolution (P3), and the admin UI (P4) follow. Core model - Mount root = a real storage.folders row; authorization for everything inside collapses onto that folder UUID (ltree-ancestry grant cascade). - Children are virtual, addressed by ext:: where node_id is provider-owned and opaque to the rest of the system. - A lock-free (arc-swap) MountRegistry maps mount-root UUID -> provider; a thin MountRouter::classify() is the single cheap hook handlers call before parsing an id as a UUID. With no mounts configured it always returns Regular, so existing code paths are unchanged. Added - migrations/20260805000000_external_mounts.sql (storage.external_mounts, kind + config JSONB) - domain/services/external_mount_id (id envelope + virtual etags) - application/ports/external_mount_ports (ExternalMountProvider, MountProviderFactory, repo port) - infrastructure local_fs_mount_provider (tokio::fs, symlink-escape-safe) + factory - application MountRegistry + MountRouter, pg ExternalMountRepository - DI wiring (AppState.mount_router), FeaturesConfig.enable_external_mounts - listing branch (FolderService::list_mount_dir_with_perms + folder_handler) and download branch (FileRetrievalService stat/open mount methods + file_handler) Authorization stays in the service layer (authz.require(Resource::Folder(mount_id))); handlers only classify. Cross-backend operations are out of scope for P1. Tests: 529 unit tests + 5 testcontainers integration tests (real Postgres 17), including end-to-end authorization (owner allowed, stranger denied). Line coverage of the new modules is 84–100% (cargo-llvm-cov). Known gap: file_handler::download_mount_file (HTTP glue) needs a full-app test (P4). --- Cargo.lock | 527 ++++++++- Cargo.toml | 5 + migrations/20260805000000_external_mounts.sql | 47 + src/application/ports/external_mount_ports.rs | 192 ++++ src/application/ports/mod.rs | 1 + .../services/batch_operations_test.rs | 7 +- .../services/external_mount_router.rs | 181 +++ .../services/file_retrieval_service.rs | 64 ++ src/application/services/folder_service.rs | 479 +++++++- src/application/services/mod.rs | 2 + src/application/services/mount_registry.rs | 374 ++++++ src/common/config.rs | 14 + src/common/di.rs | 32 + src/domain/services/external_mount_id.rs | 258 +++++ src/domain/services/mod.rs | 1 + .../pg/external_mount_repository.rs | 154 +++ src/infrastructure/repositories/pg/mod.rs | 2 + .../services/local_fs_mount_provider.rs | 1020 +++++++++++++++++ src/infrastructure/services/mod.rs | 2 + .../services/mount_provider_factory.rs | 122 ++ src/interfaces/api/handlers/file_handler.rs | 161 +++ src/interfaces/api/handlers/folder_handler.rs | 201 ++++ src/lib.rs | 6 + src/mount_it_support.rs | 118 ++ 24 files changed, 3941 insertions(+), 29 deletions(-) create mode 100644 migrations/20260805000000_external_mounts.sql create mode 100644 src/application/ports/external_mount_ports.rs create mode 100644 src/application/services/external_mount_router.rs create mode 100644 src/application/services/mount_registry.rs create mode 100644 src/domain/services/external_mount_id.rs create mode 100644 src/infrastructure/repositories/pg/external_mount_repository.rs create mode 100644 src/infrastructure/services/local_fs_mount_provider.rs create mode 100644 src/infrastructure/services/mount_provider_factory.rs create mode 100644 src/mount_it_support.rs diff --git a/Cargo.lock b/Cargo.lock index 08684cc4..96de9a9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,22 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -1087,6 +1103,83 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "chrono", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.4.0", + "http-body-util", + "hyper 1.9.0", + "hyper-named-pipe", + "hyper-rustls 0.27.9", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.49.1-rc.28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "chrono", + "prost", + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "bon" version = "3.9.2" @@ -1143,6 +1236,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1280,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" dependencies = [ "heck", - "indexmap", + "indexmap 2.14.0", "log", "proc-macro2", "quote", @@ -2012,6 +2114,17 @@ dependencies = [ "syn", ] +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2203,6 +2316,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", +] + [[package]] name = "euclid" version = "0.20.14" @@ -2396,6 +2520,16 @@ dependencies = [ "flate2", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2745,7 +2879,7 @@ checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ "fnv", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -2778,7 +2912,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2797,7 +2931,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2815,6 +2949,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -3068,6 +3208,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -3100,6 +3255,19 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3123,6 +3291,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3315,6 +3498,17 @@ dependencies = [ "quick-error", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -3691,7 +3885,7 @@ dependencies = [ "encoding_rs", "flate2", "getrandom 0.3.4", - "indexmap", + "indexmap 2.14.0", "itoa", "log", "md-5 0.10.6", @@ -4058,6 +4252,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4119,6 +4327,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4137,7 +4356,7 @@ checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" dependencies = [ "crc32fast", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "memchr", ] @@ -4220,6 +4439,7 @@ version = "0.8.0" dependencies = [ "accept-language", "aes-gcm", + "arc-swap", "argon2", "askama", "async-compression", @@ -4283,6 +4503,7 @@ dependencies = [ "sqlx", "tantivy", "tempfile", + "testcontainers-modules", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4353,6 +4574,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -4419,7 +4665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -4700,6 +4946,15 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "pulley-interpreter" version = "43.0.2" @@ -5006,6 +5261,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regalloc2" version = "0.15.1" @@ -5267,6 +5542,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -5329,6 +5613,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -5457,6 +5765,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -5478,13 +5797,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -5709,7 +6060,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -5823,7 +6174,7 @@ dependencies = [ "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", @@ -5898,6 +6249,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6139,6 +6513,44 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "testcontainers" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3ac71069f20ecfa60c396316c283fbf35e6833a53dff551a31b5458da05edc" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.10.0", + "futures", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "ulid", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1966329d5bb3f89d33602d2db2da971fb839f9297dad16527abf4564e2ae0a6d" +dependencies = [ + "testcontainers", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6334,7 +6746,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", @@ -6349,7 +6761,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", @@ -6382,7 +6794,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -6403,6 +6815,46 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.4", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -6411,9 +6863,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -6586,6 +7041,16 @@ dependencies = [ "syn", ] +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + [[package]] name = "unicase" version = "2.9.0" @@ -6725,7 +7190,7 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "utoipa-gen", @@ -6931,7 +7396,7 @@ dependencies = [ "anyhow", "heck", "im-rc", - "indexmap", + "indexmap 2.14.0", "log", "petgraph", "serde", @@ -6980,7 +7445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -7006,7 +7471,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -7018,7 +7483,7 @@ checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ "bitflags", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "semver", "serde", ] @@ -7030,7 +7495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ "bitflags", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -7111,7 +7576,7 @@ dependencies = [ "cranelift-entity", "gimli", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "log", "object", "postcard", @@ -7298,7 +7763,7 @@ dependencies = [ "anyhow", "bitflags", "heck", - "indexmap", + "indexmap 2.14.0", "wit-parser 0.245.1", ] @@ -7837,7 +8302,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -7868,7 +8333,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -7887,7 +8352,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7906,7 +8371,7 @@ dependencies = [ "anyhow", "hashbrown 0.16.1", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7934,6 +8399,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -8065,7 +8540,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", "typed-path", "zopfli", diff --git a/Cargo.toml b/Cargo.toml index 26ad217b..da47dfef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ async-trait = "0.1.89" mime_guess = "2.0.5" uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } thiserror = "2.0.18" +arc-swap = "1.9" mockall = { version = "0.14.0", optional = true } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls", "chrono", "uuid", "json", "migrate"] } @@ -122,6 +123,10 @@ bench = [] [dev-dependencies] criterion = "0.5" +# Ephemeral service containers for integration tests (gated by +# `--cfg integration_tests`). `testcontainers-modules` re-exports the +# `testcontainers` runner, so the postgres image + runner come from one dep. +testcontainers-modules = { version = "0.13", features = ["postgres"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } diff --git a/migrations/20260805000000_external_mounts.sql b/migrations/20260805000000_external_mounts.sql new file mode 100644 index 00000000..0f3f4088 --- /dev/null +++ b/migrations/20260805000000_external_mounts.sql @@ -0,0 +1,47 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- External file mounts +-- ════════════════════════════════════════════════════════════════════════════ +-- Admin-configured mounts that expose an external backend (raw host filesystem +-- in v1; SFTP/WebDAV/… as future provider `kind`s) as a folder inside a user's +-- drive. The mount ROOT is a normal `storage.folders` row (so it participates in +-- ltree, drive scoping, and ACL grants like any folder); everything BELOW it is +-- virtual — read live from the backend, never stored in `storage.files`. +-- +-- This table maps a mount-root folder to its backend. `kind` selects the +-- provider implementation; `config` carries provider-specific connection data +-- (a stable JSONB bag so adding a new provider kind needs no schema change): +-- * local_fs → {"path": "/mnt/share"} +-- * sftp (future) → {"host": "...", "port": 22, "user": "...", "base_path": "..."} +-- +-- Mount contents are deliberately a LIMITED, SEPARATE storage type: no blob +-- dedup, no per-file sharing/favorites/trash/search in v1 (deletes are real, +-- permanent backend deletes). See docs / plan for the forward path. +-- ════════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS storage.external_mounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- The mount-root folder. Deleting that folder row removes the mount mapping. + mount_folder_id UUID NOT NULL UNIQUE + REFERENCES storage.folders(id) ON DELETE CASCADE, + -- Provider discriminator (selects the ExternalMountProvider implementation). + kind TEXT NOT NULL DEFAULT 'local_fs', + -- Provider-specific connection config; shape depends on `kind`. + config JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Display name (mirrors the folder name; kept for admin listings). + name TEXT NOT NULL, + -- Admin/user who owns the mount configuration. + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + -- When true, the provider refuses all mutations (browse/download only). + read_only BOOLEAN NOT NULL DEFAULT FALSE, + -- Visibility policy. 'owner' = only the owner's drive sees it (v1). + -- Reserved for future 'shared' semantics. + visibility TEXT NOT NULL DEFAULT 'owner', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_external_mounts_folder + ON storage.external_mounts(mount_folder_id); + +COMMENT ON TABLE storage.external_mounts IS + 'Admin-configured external backends (local_fs/sftp/…) surfaced as a mount-root folder; contents are virtual and read live from the provider.'; diff --git a/src/application/ports/external_mount_ports.rs b/src/application/ports/external_mount_ports.rs new file mode 100644 index 00000000..b723ab75 --- /dev/null +++ b/src/application/ports/external_mount_ports.rs @@ -0,0 +1,192 @@ +//! External mount provider port — the pluggable backend abstraction for mounts. +//! +//! An [`ExternalMountProvider`] exposes a filesystem-style I/O surface for one +//! mount's backend (raw host fs in v1; SFTP/WebDAV/… later). It is the *lowest +//! common denominator* of browse + CRUD: deliberately small, so new backend +//! `kind`s drop in without touching the router, listing, authz, or path +//! resolution. Rich native features (sharing, trash, search, …) are NOT part of +//! this trait — they compose above it. +//! +//! Each provider instance is **bound to one mount's root location at +//! construction**, so methods take only a provider-owned [`NodeId`], never a host +//! `Path` (an SFTP/WebDAV provider has no local path). The `NodeId` is opaque to +//! the rest of the system (see [`crate::domain::services::external_mount_id`]). +//! +//! The trait returns boxed futures via `#[async_trait]` and takes a boxed write +//! stream, so it is dyn-compatible (`Arc`) and a +//! single mount registry can hold providers of different kinds. + +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; +use std::pin::Pin; +use uuid::Uuid; + +use crate::application::ports::blob_storage_ports::BlobStream; +use crate::domain::errors::DomainError; +use crate::domain::services::external_mount_id::NodeId; + +/// A byte stream handed to [`ExternalMountProvider::write_stream`]. +/// +/// Boxed (not generic) so the trait stays object-safe. Callers map their body's +/// error type to `std::io::Error` before constructing it. +pub type MountByteStream = Pin> + Send>>; + +/// One entry returned by [`ExternalMountProvider::list_dir`]. +#[derive(Debug, Clone)] +pub struct MountEntry { + /// Final path segment (display name). + pub name: String, + /// Provider-assigned, opaque identity for this entry. + pub node_id: NodeId, + /// Whether the entry is a directory. + pub is_dir: bool, + /// Size in bytes (0 for directories). + pub size: u64, + /// Last-modified time, unix seconds. + pub modified_at: u64, + /// Creation time, unix seconds (falls back to `modified_at` when unavailable). + pub created_at: u64, +} + +/// Metadata for a single entry returned by [`ExternalMountProvider::stat`] and +/// by the mutating ops (so the caller learns the new entry's `node_id`). +#[derive(Debug, Clone)] +pub struct MountStat { + /// Provider-assigned, opaque identity for this entry. + pub node_id: NodeId, + /// Whether the entry is a directory. + pub is_dir: bool, + /// Size in bytes (0 for directories). + pub size: u64, + /// Last-modified time, unix seconds. + pub modified_at: u64, + /// Creation time, unix seconds. + pub created_at: u64, + /// MIME type (sniffed from extension for files; `"directory"` for dirs). + pub mime_type: String, +} + +/// Static capability flags a provider advertises. +#[derive(Debug, Clone, Copy)] +pub struct MountCaps { + /// Provider can serve byte ranges (HTTP Range / partial reads). + pub supports_range: bool, + /// Provider refuses all mutations. + pub read_only: bool, + /// `node_id`s are stable across renames/moves (e.g. inode / object id). + /// `false` for path-based providers — relevant to the future sharing path. + pub stable_ids: bool, +} + +/// Pluggable I/O surface for one mount's backend, bound to its root location. +/// +/// Implementations: `LocalFsMountProvider` (v1). All node ids are +/// provider-owned and opaque; the system never parses them. +#[async_trait] +pub trait ExternalMountProvider: Send + Sync + 'static { + /// Provider kind identifier (matches the `kind` column / factory arm). + fn kind(&self) -> &'static str; + + /// Static capabilities. + fn capabilities(&self) -> MountCaps; + + /// Map an internal path (relative to the mount root) to a `node_id`. + /// + /// For path-based providers this is identity (the default). Providers whose + /// identity is not a path override this. Does not assert existence — use + /// [`stat`](Self::stat) for that. + fn resolve_path(&self, path: &str) -> NodeId { + NodeId(path.to_string()) + } + + /// List the directory identified by `node_id` (root = the provider's bound + /// location, addressed via `resolve_path("")`). + async fn list_dir(&self, node_id: &NodeId) -> Result, DomainError>; + + /// Stat a single entry. + async fn stat(&self, node_id: &NodeId) -> Result; + + /// Open a (optionally ranged) read stream over a file's bytes. + /// + /// `range` is `(start, end_inclusive_opt)`; `None` reads the whole file. + async fn open_read_stream( + &self, + node_id: &NodeId, + range: Option<(u64, Option)>, + ) -> Result; + + /// Create a child directory `name` under `parent`. Returns the new dir's stat. + async fn create_dir(&self, parent: &NodeId, name: &str) -> Result; + + /// Stream-write a child file `name` under `parent`. Returns the new file's stat. + async fn write_stream( + &self, + parent: &NodeId, + name: &str, + body: MountByteStream, + ) -> Result; + + /// Rename an entry in place (same parent). Returns the renamed entry's stat. + async fn rename(&self, node_id: &NodeId, new_name: &str) -> Result; + + /// Delete an entry (recursively for directories). Permanent — no trash. + async fn delete(&self, node_id: &NodeId) -> Result<(), DomainError>; + + /// Move an entry into `dest_parent`, keeping its name. Returns the new stat. + async fn move_within( + &self, + node_id: &NodeId, + dest_parent: &NodeId, + ) -> Result; +} + +/// A persisted external mount joined with its mount-root folder. +/// +/// Returned by [`ExternalMountRepositoryPort::list_all`] to (re)build the +/// in-memory registry. +#[derive(Debug, Clone)] +pub struct ExternalMountRecord { + /// The mount-root folder UUID (also the mount's identity in the registry). + pub mount_folder_id: Uuid, + /// Provider kind (factory discriminator). + pub kind: String, + /// Provider-specific connection config. + pub config: serde_json::Value, + /// Display name. + pub name: String, + /// Owner of the mount configuration. + pub owner_id: Uuid, + /// Whether the mount is read-only. + pub read_only: bool, + /// Drive the mount-root folder belongs to (for path resolution). + pub drive_id: Uuid, + /// Materialized internal path of the mount-root folder (for path resolution). + pub mount_path: String, +} + +/// Persistence port for external mount configuration. +#[async_trait] +pub trait ExternalMountRepositoryPort: Send + Sync { + /// Load every (non-trashed) mount joined with its folder, for registry build. + async fn list_all(&self) -> Result, DomainError>; +} + +/// Builds [`ExternalMountProvider`]s from a `kind` + `config` pair. +/// +/// The single extension point for new backends: adding a provider is +/// implementing the trait plus one arm here. +#[async_trait] +pub trait MountProviderFactory: Send + Sync { + /// Construct a provider for `kind`, parsing its `config` JSON. + /// + /// Errors with `UnsupportedOperation` for an unknown kind, or + /// `validation_error` for malformed config. + async fn build( + &self, + kind: &str, + config: &serde_json::Value, + ) -> Result, DomainError>; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index ba8fd2b3..55111d00 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -10,6 +10,7 @@ pub mod compression_ports; pub mod content_index_ports; pub mod dedup_ports; pub mod email_sender; +pub mod external_mount_ports; pub mod face_ports; pub mod favorites_ports; pub mod file_lifecycle; diff --git a/src/application/services/batch_operations_test.rs b/src/application/services/batch_operations_test.rs index a01e145c..0148c075 100644 --- a/src/application/services/batch_operations_test.rs +++ b/src/application/services/batch_operations_test.rs @@ -100,7 +100,12 @@ mod tests { None, authz.clone(), )); - let folder_service = Arc::new(FolderService::new(folder_repo, authz)); + let mount_router = Arc::new( + crate::application::services::external_mount_router::MountRouter::new(Arc::new( + crate::application::services::mount_registry::MountRegistry::empty(), + )), + ); + let folder_service = Arc::new(FolderService::new(folder_repo, authz, mount_router)); let _batch_service = BatchOperationService::new( file_retrieval, diff --git a/src/application/services/external_mount_router.rs b/src/application/services/external_mount_router.rs new file mode 100644 index 00000000..35dbd9d3 --- /dev/null +++ b/src/application/services/external_mount_router.rs @@ -0,0 +1,181 @@ +//! Classifies file/folder ids into native vs. external-mount handling. +//! +//! This is the single, cheap hook the service layer calls before any +//! `Uuid::parse_str`, so synthetic `ext:` ids and mount-root UUIDs branch to the +//! provider while everything else flows to the PostgreSQL repositories unchanged. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::application::services::mount_registry::{MountConfig, MountRegistry}; +use crate::domain::services::external_mount_id::{NodeId, is_external_id, parse_child_id}; + +/// The result of classifying an id. +pub enum ResolvedId { + /// Plain native resource (UUID not registered as a mount root, or an + /// unrecognized id). Handle exactly as today. + Regular, + /// A real UUID that IS a mount root. Listing/metadata branch to the provider; + /// the row itself still exists natively. + MountRoot { cfg: Arc }, + /// A synthetic id addressing an entry inside a mount. + MountChild { + cfg: Arc, + node_id: NodeId, + }, +} + +/// Thin, cloneable classifier over the mount registry. +#[derive(Clone)] +pub struct MountRouter { + registry: Arc, +} + +impl MountRouter { + /// Construct from the shared registry. + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Borrow the underlying registry (for path-based resolution / admin reload). + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// Fast path: are there no mounts at all? Lets callers skip classification. + pub fn is_empty(&self) -> bool { + self.registry.is_empty() + } + + /// Classify an id. Never parses a provider `node_id` — only the envelope. + pub fn classify(&self, id: &str) -> ResolvedId { + if is_external_id(id) { + if let Some(child) = parse_child_id(id) + && let Some(cfg) = self.registry.get(&child.mount_id) + { + return ResolvedId::MountChild { + cfg, + node_id: child.node_id, + }; + } + // Malformed or dangling `ext:` id — fall through to Regular so it + // surfaces a clean NotFound downstream rather than hitting the repos. + return ResolvedId::Regular; + } + if let Ok(uuid) = Uuid::parse_str(id) + && let Some(cfg) = self.registry.get(&uuid) + { + return ResolvedId::MountRoot { cfg }; + } + ResolvedId::Regular + } + + /// True when `id` addresses anything inside a mount (root or child). + pub fn is_mount_id(&self, id: &str) -> bool { + matches!( + self.classify(id), + ResolvedId::MountRoot { .. } | ResolvedId::MountChild { .. } + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::ports::external_mount_ports::{ + ExternalMountRecord, ExternalMountRepositoryPort, + }; + use crate::domain::errors::DomainError; + use crate::domain::services::external_mount_id::encode_child_id; + use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory; + use async_trait::async_trait; + use tempfile::TempDir; + + struct FakeRepo(Vec); + #[async_trait] + impl ExternalMountRepositoryPort for FakeRepo { + async fn list_all(&self) -> Result, DomainError> { + Ok(self.0.clone()) + } + } + + async fn router_with_mount(mount_id: Uuid, dir: &TempDir) -> MountRouter { + let repo = FakeRepo(vec![ExternalMountRecord { + mount_folder_id: mount_id, + kind: "local_fs".to_string(), + config: serde_json::json!({ "path": dir.path().to_str().unwrap() }), + name: "M".to_string(), + owner_id: Uuid::new_v4(), + read_only: false, + drive_id: Uuid::new_v4(), + mount_path: "Personal/M".to_string(), + }]); + let reg = Arc::new(MountRegistry::empty()); + reg.reload(&repo, &DefaultMountProviderFactory::new()).await; + MountRouter::new(reg) + } + + #[test] + fn empty_registry_classifies_everything_regular() { + let router = MountRouter::new(Arc::new(MountRegistry::empty())); + assert!(router.is_empty()); + assert!(matches!( + router.classify(&Uuid::new_v4().to_string()), + ResolvedId::Regular + )); + assert!(matches!( + router.classify("ext:deadbeef:dG9rZW4"), + ResolvedId::Regular + )); + assert!(matches!(router.classify("garbage"), ResolvedId::Regular)); + } + + #[tokio::test] + async fn classifies_mount_root_uuid() { + let dir = TempDir::new().unwrap(); + let mount_id = Uuid::new_v4(); + let router = router_with_mount(mount_id, &dir).await; + + match router.classify(&mount_id.to_string()) { + ResolvedId::MountRoot { cfg } => assert_eq!(cfg.mount_id, mount_id), + _ => panic!("expected MountRoot"), + } + assert!(router.is_mount_id(&mount_id.to_string())); + } + + #[tokio::test] + async fn classifies_ext_child_id() { + let dir = TempDir::new().unwrap(); + let mount_id = Uuid::new_v4(); + let router = router_with_mount(mount_id, &dir).await; + + let child = encode_child_id(mount_id, "docs/a.txt"); + match router.classify(&child) { + ResolvedId::MountChild { cfg, node_id } => { + assert_eq!(cfg.mount_id, mount_id); + assert_eq!(node_id.as_str(), "docs/a.txt"); + } + _ => panic!("expected MountChild"), + } + assert!(router.is_mount_id(&child)); + } + + #[tokio::test] + async fn ext_id_for_unregistered_mount_is_regular() { + let dir = TempDir::new().unwrap(); + let mount_id = Uuid::new_v4(); + let router = router_with_mount(mount_id, &dir).await; + + // A well-formed ext: id but for a DIFFERENT (unknown) mount → Regular, + // so it 404s downstream rather than hitting the repos. + let dangling = encode_child_id(Uuid::new_v4(), "x"); + assert!(matches!(router.classify(&dangling), ResolvedId::Regular)); + + // A plain (non-mount) UUID is also Regular. + assert!(matches!( + router.classify(&Uuid::new_v4().to_string()), + ResolvedId::Regular + )); + } +} diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 490a423e..d74e0cbe 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -5,10 +5,14 @@ use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::blob_storage_ports::BlobStream; +use crate::application::ports::external_mount_ports::MountStat; use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent}; use crate::application::ports::storage_ports::FileReadPort; +use crate::application::services::mount_registry::MountConfig; use crate::common::errors::DomainError; use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::domain::services::external_mount_id::NodeId; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::image_transcode_service::{ @@ -64,6 +68,22 @@ impl FileRetrievalService { } } + /// Test-only constructor: authorization engine without the cache/transcode + /// tiers. The external-mount read methods only consult `authz` + the + /// provider, so this is sufficient to exercise their authorization. + #[cfg(all(test, integration_tests))] + pub(crate) fn new_with_authz_for_test( + file_read: Arc, + authz: Arc, + ) -> Self { + Self { + file_read, + content_cache: None, + transcode: None, + authz: Some(authz), + } + } + // ── private helpers ────────────────────────────────────────── /// Read a file's full content through the streaming API into a single @@ -122,6 +142,50 @@ impl FileRetrievalService { .await } + /// Authorize then `stat` a file inside an external mount. Authorization + /// collapses onto the mount-root folder (a `Read` grant there covers + /// everything in the mount). + pub async fn stat_mount_file_with_perms( + &self, + cfg: &MountConfig, + node_id: &NodeId, + caller_id: Uuid, + ) -> Result { + let authz = self.authz.as_ref().ok_or_else(|| { + DomainError::internal_error("FileRetrieval", "Authorization engine unavailable") + })?; + authz + .require( + Subject::User(caller_id), + Permission::Read, + Resource::Folder(cfg.mount_id), + ) + .await?; + cfg.provider.stat(node_id).await + } + + /// Authorize then open a (optionally ranged) read stream over a mount file. + /// `range` is `(start, end_inclusive_opt)`. + pub async fn open_mount_file_with_perms( + &self, + cfg: &MountConfig, + node_id: &NodeId, + caller_id: Uuid, + range: Option<(u64, Option)>, + ) -> Result { + let authz = self.authz.as_ref().ok_or_else(|| { + DomainError::internal_error("FileRetrieval", "Authorization engine unavailable") + })?; + authz + .require( + Subject::User(caller_id), + Permission::Read, + Resource::Folder(cfg.mount_id), + ) + .await?; + cfg.provider.open_read_stream(node_id, range).await + } + /// Try to transcode image content to WebP and return transcoded variant. async fn try_transcode( &self, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 9b5cb9fc..a6843ba4 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -4,10 +4,14 @@ use crate::application::dtos::folder_dto::{ MoveFolderDto, RenameFolderDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::external_mount_ports::MountEntry; use crate::application::ports::folder_ports::FolderUseCase; +use crate::application::services::external_mount_router::MountRouter; +use crate::application::services::mount_registry::MountConfig; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject}; +use crate::domain::services::external_mount_id::NodeId; use crate::domain::services::path_service::{StoragePath, validate_storage_name}; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; @@ -18,17 +22,31 @@ use uuid::Uuid; pub struct FolderService { folder_storage: Arc, authz: Arc, + /// External-mount classifier. Lets folder operations branch a mount-root or + /// `ext:` id onto the provider instead of the PostgreSQL repositories. + mount_router: Arc, } impl FolderService { /// Creates a new folder service - pub fn new(folder_storage: Arc, authz: Arc) -> Self { + pub fn new( + folder_storage: Arc, + authz: Arc, + mount_router: Arc, + ) -> Self { Self { folder_storage, authz, + mount_router, } } + /// Borrow the external-mount classifier (handlers branch on this before + /// treating an id as a native UUID). + pub fn mount_router(&self) -> &MountRouter { + &self.mount_router + } + /// Batch counterpart of `get_folder`: resolve many folder ids in ONE /// query instead of one per id. Like `get_folder` it performs no /// per-folder authorization — both current callers (ACL grant listing, @@ -615,6 +633,125 @@ impl FolderService { Ok((rows, next_cursor)) } + + /// List one directory inside an external mount (the mount root when + /// `node_id` is empty, or a nested virtual folder otherwise). + /// + /// Authorization collapses onto the mount-root folder: a caller who may + /// `Read` the mount root may browse everything inside it. The provider + /// reads the live backend; entries are sorted in memory and paginated with + /// a name-keyset cursor (directories are bounded, see provider cap). + /// + /// Returns the page of raw [`MountEntry`]s plus an encoded next cursor; the + /// handler maps each entry to a `FolderResourceItemDto` with a synthetic + /// `ext:` id. + pub async fn list_mount_dir_with_perms( + &self, + cfg: &MountConfig, + node_id: &NodeId, + caller_id: Uuid, + opts: ListResourcesOptions<'_>, + ) -> Result<(Vec, Option), DomainError> { + // AuthZ — everything in the mount is gated by the mount-root folder. + self.authz + .require( + Subject::User(caller_id), + Permission::Read, + Resource::Folder(cfg.mount_id), + ) + .await?; + + let entries = cfg.provider.list_dir(node_id).await?; + let cursor_name = opts.cursor.as_ref().and_then(|c| c.sort_str.as_deref()); + Ok(paginate_mount_entries( + entries, + opts.kinds, + opts.order_by, + opts.reverse, + opts.limit, + cursor_name, + )) + } +} + +/// Filter, sort, and page a directory's worth of mount entries, returning the +/// page plus an encoded next cursor. Pure (no I/O / authz) so it can be tested +/// exhaustively. +/// +/// The cursor is a **name keyset**: names are unique within a directory, so the +/// last emitted name is a stable resume key under any sort dimension. Resume is +/// best-effort — if the cursor's entry was deleted out-of-band the page restarts +/// from the top (documented; avoids an infinite loop). +fn paginate_mount_entries( + mut entries: Vec, + kinds: Option<&[ResourceKind]>, + order_by: &str, + reverse: bool, + limit: usize, + cursor_name: Option<&str>, +) -> (Vec, Option) { + if let Some(kinds) = kinds { + let want_files = kinds.contains(&ResourceKind::File); + let want_folders = kinds.contains(&ResourceKind::Folder); + entries.retain(|e| if e.is_dir { want_folders } else { want_files }); + } + + sort_mount_entries(&mut entries, order_by, reverse); + + let start = match cursor_name { + Some(name) => entries + .iter() + .position(|e| name.eq_ignore_ascii_case(&e.name)) + .map(|i| i + 1) + .unwrap_or(0), + None => 0, + }; + + let has_more = entries.len() > start + limit; + let page: Vec = entries.into_iter().skip(start).take(limit).collect(); + + let next_cursor = if has_more { + page.last().map(|last| { + FolderResourceCursor { + order_by: order_by.to_owned(), + resource_id: Uuid::nil(), + sort_str: Some(last.name.clone()), + sort_int: None, + sort_ts: None, + reverse, + } + .encode() + }) + } else { + None + }; + + (page, next_cursor) +} + +/// Sort mount entries in place. Folders sort before files for the `name`/`type` +/// dimensions; otherwise by the requested key with name as the tie-breaker. +/// `reverse` flips the final order. +fn sort_mount_entries(entries: &mut [MountEntry], order_by: &str, reverse: bool) { + use std::cmp::Ordering; + let name_key = |e: &MountEntry| e.name.to_lowercase(); + entries.sort_by(|a, b| { + let primary = match order_by { + "modified_at" => a.modified_at.cmp(&b.modified_at), + "created_at" => a.created_at.cmp(&b.created_at), + "size" => a.size.cmp(&b.size), + // "name" / "type" / anything else: folders first, then by name. + _ => b.is_dir.cmp(&a.is_dir), + }; + let ord = primary.then_with(|| name_key(a).cmp(&name_key(b))); + if ord == Ordering::Equal { + Ordering::Equal + } else if reverse { + ord.reverse() + } else { + ord + } + }); } /// Build the next-page cursor from the last row of the current page. @@ -842,3 +979,341 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook { Ok(()) } } + +#[cfg(test)] +mod mount_listing_tests { + use super::{paginate_mount_entries, sort_mount_entries}; + use crate::application::dtos::cursor::PageCursor; + use crate::application::dtos::folder_dto::FolderResourceCursor; + use crate::application::ports::external_mount_ports::MountEntry; + use crate::domain::services::authorization::ResourceKind; + use crate::domain::services::external_mount_id::NodeId; + + fn entry(name: &str, is_dir: bool, size: u64, modified: u64) -> MountEntry { + MountEntry { + name: name.to_string(), + node_id: NodeId(name.to_string()), + is_dir, + size, + modified_at: modified, + created_at: modified, + } + } + + fn names(entries: &[MountEntry]) -> Vec { + entries.iter().map(|e| e.name.clone()).collect() + } + + #[test] + fn sorts_folders_first_then_name_case_insensitive() { + let mut e = vec![ + entry("Banana.txt", false, 1, 1), + entry("apple", true, 0, 1), + entry("Cherry", true, 0, 1), + entry("almond.txt", false, 1, 1), + ]; + sort_mount_entries(&mut e, "name", false); + assert_eq!(names(&e), ["apple", "Cherry", "almond.txt", "Banana.txt"]); + } + + #[test] + fn reverse_flips_order() { + let mut e = vec![ + entry("a", false, 1, 1), + entry("b", false, 1, 1), + entry("d", true, 0, 1), + ]; + sort_mount_entries(&mut e, "name", true); + // folders-first then name, reversed. + assert_eq!(names(&e), ["b", "a", "d"]); + } + + #[test] + fn sorts_by_size_modified_created() { + let mut by_size = vec![ + entry("big", false, 100, 1), + entry("small", false, 1, 1), + entry("mid", false, 50, 1), + ]; + sort_mount_entries(&mut by_size, "size", false); + assert_eq!(names(&by_size), ["small", "mid", "big"]); + + let mut by_mtime = vec![ + entry("new", false, 1, 300), + entry("old", false, 1, 100), + entry("mid", false, 1, 200), + ]; + sort_mount_entries(&mut by_mtime, "modified_at", false); + assert_eq!(names(&by_mtime), ["old", "mid", "new"]); + + let mut by_ctime = vec![entry("z", false, 1, 9), entry("a", false, 1, 5)]; + sort_mount_entries(&mut by_ctime, "created_at", false); + assert_eq!(names(&by_ctime), ["a", "z"]); + } + + #[test] + fn filters_by_kind() { + let make = || vec![entry("dir", true, 0, 1), entry("file.txt", false, 1, 1)]; + let (files_only, _) = + paginate_mount_entries(make(), Some(&[ResourceKind::File]), "name", false, 50, None); + assert_eq!(names(&files_only), ["file.txt"]); + + let (folders_only, _) = paginate_mount_entries( + make(), + Some(&[ResourceKind::Folder]), + "name", + false, + 50, + None, + ); + assert_eq!(names(&folders_only), ["dir"]); + + let (both, _) = paginate_mount_entries( + make(), + Some(&[ResourceKind::File, ResourceKind::Folder]), + "name", + false, + 50, + None, + ); + assert_eq!(both.len(), 2); + } + + #[test] + fn paginates_with_name_keyset_cursor() { + let all = || { + vec![ + entry("a", false, 1, 1), + entry("b", false, 1, 1), + entry("c", false, 1, 1), + entry("d", false, 1, 1), + entry("e", false, 1, 1), + ] + }; + + // Page 1: limit 2 → [a, b], cursor present. + let (p1, c1) = paginate_mount_entries(all(), None, "name", false, 2, None); + assert_eq!(names(&p1), ["a", "b"]); + let c1 = c1.expect("cursor after first page"); + let decoded = FolderResourceCursor::decode(&c1).expect("decodes"); + assert_eq!(decoded.sort_str.as_deref(), Some("b")); + assert!(!decoded.reverse); + + // Page 2: resume after "b" → [c, d], cursor present. + let (p2, c2) = paginate_mount_entries(all(), None, "name", false, 2, Some("b")); + assert_eq!(names(&p2), ["c", "d"]); + assert!(c2.is_some()); + + // Page 3: resume after "d" → [e], no further cursor. + let (p3, c3) = paginate_mount_entries(all(), None, "name", false, 2, Some("d")); + assert_eq!(names(&p3), ["e"]); + assert!(c3.is_none()); + } + + #[test] + fn no_cursor_when_page_is_last() { + let e = vec![entry("a", false, 1, 1), entry("b", false, 1, 1)]; + let (page, cursor) = paginate_mount_entries(e, None, "name", false, 50, None); + assert_eq!(page.len(), 2); + assert!(cursor.is_none()); + } + + #[test] + fn deleted_cursor_entry_restarts_best_effort() { + // Cursor names "zzz" which is not present → start from the top. + let e = vec![entry("a", false, 1, 1), entry("b", false, 1, 1)]; + let (page, _) = paginate_mount_entries(e, None, "name", false, 50, Some("zzz")); + assert_eq!(names(&page), ["a", "b"]); + } + + #[test] + fn empty_directory_yields_empty_page() { + let (page, cursor) = paginate_mount_entries(vec![], None, "name", false, 50, None); + assert!(page.is_empty()); + assert!(cursor.is_none()); + } + + #[test] + fn limit_larger_than_len_returns_all_without_cursor() { + let e = vec![entry("a", false, 1, 1), entry("b", false, 1, 1)]; + let (page, cursor) = paginate_mount_entries(e, None, "name", false, 100, None); + assert_eq!(page.len(), 2); + assert!(cursor.is_none()); + } + + #[test] + fn kind_filter_excluding_all_yields_empty() { + let e = vec![entry("only_dir", true, 0, 1)]; + let (page, cursor) = + paginate_mount_entries(e, Some(&[ResourceKind::File]), "name", false, 50, None); + assert!(page.is_empty()); + assert!(cursor.is_none()); + } + + #[test] + fn cursor_preserves_reverse_flag() { + let e = vec![ + entry("a", false, 1, 1), + entry("b", false, 1, 1), + entry("c", false, 1, 1), + ]; + let (_p, c) = paginate_mount_entries(e, None, "name", true, 1, None); + let decoded = FolderResourceCursor::decode(&c.unwrap()).unwrap(); + assert!(decoded.reverse); + assert_eq!(decoded.order_by, "name"); + } +} + +#[cfg(all(test, integration_tests))] +mod mount_authz_integration { + use super::*; + use crate::application::dtos::folder_dto::ListResourcesOptions; + use crate::application::services::external_mount_router::{MountRouter, ResolvedId}; + use crate::application::services::file_retrieval_service::FileRetrievalService; + use crate::application::services::mount_registry::MountRegistry; + use crate::domain::services::external_mount_id::{NodeId, encode_child_id}; + use crate::infrastructure::repositories::pg::{ + ExternalMountPgRepository, FileBlobReadRepository, SubjectGroupPgRepository, + }; + use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory; + use crate::mount_it_support::{fresh_db, insert_mount, make_user, provision_folder}; + use std::sync::Arc; + + fn opts<'a>() -> ListResourcesOptions<'a> { + ListResourcesOptions { + limit: 50, + cursor: None, + order_by: "name", + kinds: None, + reverse: false, + } + } + + /// Build a real PgAclEngine over the live pool. The folder-ancestry cascade + /// uses the engine's own pool; the file repo is a stub (not exercised by + /// folder checks). + fn acl(pool: &Arc) -> Arc { + Arc::new(PgAclEngine::new( + pool.clone(), + Arc::new(FolderDbRepository::new(pool.clone())), + Arc::new(FileBlobReadRepository::new_stub()), + Arc::new(SubjectGroupPgRepository::new(pool.clone())), + )) + } + + /// Full read path: owner can list a mount's live contents; a stranger with + /// no grant is denied. Exercises the REAL authorization cascade + /// (`authz.require(Resource::Folder(mount_id))`) over ltree ancestry. + #[tokio::test] + async fn owner_lists_mount_contents_stranger_denied() { + let (_c, pool) = fresh_db().await; + + // Real host directory the mount points at. + let host = tempfile::tempdir().unwrap(); + std::fs::write(host.path().join("a.txt"), b"hello").unwrap(); + std::fs::create_dir(host.path().join("sub")).unwrap(); + + let p = provision_folder(&pool, "owner", "Media").await; + insert_mount(&pool, &p, host.path().to_str().unwrap()).await; + + // Build the registry from the DB (also exercises reload + provider build). + let registry = Arc::new(MountRegistry::empty()); + registry + .reload( + &ExternalMountPgRepository::new(pool.clone()), + &DefaultMountProviderFactory::new(), + ) + .await; + let router = Arc::new(MountRouter::new(registry.clone())); + let folder_service = FolderService::new( + Arc::new(FolderDbRepository::new(pool.clone())), + acl(&pool), + router.clone(), + ); + + let cfg = registry.get(&p.mount_folder_id).expect("mount registered"); + + // The mount root UUID classifies as a MountRoot. + assert!(matches!( + router.classify(&p.mount_folder_id.to_string()), + ResolvedId::MountRoot { .. } + )); + + // Owner lists the live directory contents. + let (entries, _cursor) = folder_service + .list_mount_dir_with_perms(&cfg, &NodeId::default(), p.owner_id, opts()) + .await + .expect("owner may list"); + let mut names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect(); + names.sort(); + assert_eq!(names, ["a.txt", "sub"]); + + // A stranger with no grant on the mount-root folder is denied + // (NotFound — anti-enumeration). + let stranger = make_user(&pool, "stranger").await; + let err = folder_service + .list_mount_dir_with_perms(&cfg, &NodeId::default(), stranger, opts()) + .await + .expect_err("stranger must be denied"); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + /// Download path authz: owner can stat/open a mount file; stranger denied. + #[tokio::test] + async fn owner_reads_mount_file_stranger_denied() { + let (_c, pool) = fresh_db().await; + + let host = tempfile::tempdir().unwrap(); + std::fs::write(host.path().join("doc.txt"), b"payload").unwrap(); + + let p = provision_folder(&pool, "owner", "Media").await; + insert_mount(&pool, &p, host.path().to_str().unwrap()).await; + + let registry = Arc::new(MountRegistry::empty()); + registry + .reload( + &ExternalMountPgRepository::new(pool.clone()), + &DefaultMountProviderFactory::new(), + ) + .await; + let cfg = registry.get(&p.mount_folder_id).expect("registered"); + + let retrieval = FileRetrievalService::new_with_authz_for_test( + Arc::new(FileBlobReadRepository::new_stub()), + acl(&pool), + ); + + let node = NodeId::from("doc.txt"); + + // Owner: stat succeeds with the real size. + let stat = retrieval + .stat_mount_file_with_perms(&cfg, &node, p.owner_id) + .await + .expect("owner may stat"); + assert_eq!(stat.size, 7); + assert!(!stat.is_dir); + + // Owner: open succeeds (smoke — stream is consumed elsewhere). + assert!( + retrieval + .open_mount_file_with_perms(&cfg, &node, p.owner_id, None) + .await + .is_ok() + ); + + // The synthetic id for this file round-trips through the router. + let ext_id = encode_child_id(p.mount_folder_id, "doc.txt"); + assert!(matches!( + MountRouter::new(registry.clone()).classify(&ext_id), + ResolvedId::MountChild { .. } + )); + + // Stranger: denied. + let stranger = make_user(&pool, "stranger").await; + let err = retrieval + .stat_mount_file_with_perms(&cfg, &node, stranger) + .await + .expect_err("stranger denied"); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 578e3d98..48f56cf6 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -9,6 +9,7 @@ pub mod delta_upload_service; pub mod device_auth_service; pub mod drive_management_service; pub mod external_identity_service; +pub mod external_mount_router; pub mod favorites_service; pub mod file_lifecycle_service; pub mod file_management_service; @@ -18,6 +19,7 @@ pub mod file_use_case_factory; pub mod folder_service; pub mod i18n_application_service; pub mod magic_link_invite_service; +pub mod mount_registry; pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; diff --git a/src/application/services/mount_registry.rs b/src/application/services/mount_registry.rs new file mode 100644 index 00000000..d81f67aa --- /dev/null +++ b/src/application/services/mount_registry.rs @@ -0,0 +1,374 @@ +//! In-memory registry of configured external mounts. +//! +//! Holds, per mount-root folder UUID, the constructed provider plus the metadata +//! the service layer needs to authorize and synthesize DTOs. Reads are lock-free +//! (`arc-swap`) so the hot path ("is this UUID a mount root?") never blocks; the +//! whole index is rebuilt on admin mutation via [`MountRegistry::reload`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use uuid::Uuid; + +use crate::application::ports::external_mount_ports::{ + ExternalMountProvider, ExternalMountRepositoryPort, MountProviderFactory, +}; + +/// One configured mount, with its live provider. +pub struct MountConfig { + /// Mount-root folder UUID — the mount's identity and the authz resource. + pub mount_id: Uuid, + /// Provider kind. + pub kind: String, + /// Display name. + pub name: String, + /// Owner of the mount configuration. + pub owner_id: Uuid, + /// Drive the mount root belongs to. + pub drive_id: Uuid, + /// Whether the mount refuses mutations. + pub read_only: bool, + /// Materialized internal path of the mount root (e.g. `"Personal/Media"`), + /// used by path-based resolution for WebDAV / NextCloud. + pub mount_path: String, + /// The bound provider for this mount's backend. + pub provider: Arc, +} + +/// Immutable snapshot swapped atomically on reload. +#[derive(Default)] +struct MountIndex { + /// mount-root folder UUID → config. + by_folder: HashMap>, + /// (drive_id, mount_path) → mount-root UUID, for path resolution (P3). + by_path: HashMap<(Uuid, String), Uuid>, +} + +/// Lock-free registry of mounts. +pub struct MountRegistry { + inner: ArcSwap, +} + +impl Default for MountRegistry { + fn default() -> Self { + Self::empty() + } +} + +impl MountRegistry { + /// An empty registry (no mounts). + pub fn empty() -> Self { + Self { + inner: ArcSwap::from_pointee(MountIndex::default()), + } + } + + /// Look up a mount by its root folder UUID. + pub fn get(&self, mount_id: &Uuid) -> Option> { + self.inner.load().by_folder.get(mount_id).cloned() + } + + /// Is this UUID the root of a configured mount? + pub fn is_mount_root(&self, id: &Uuid) -> bool { + self.inner.load().by_folder.contains_key(id) + } + + /// True when no mounts are configured (lets callers skip work entirely). + pub fn is_empty(&self) -> bool { + self.inner.load().by_folder.is_empty() + } + + /// Find the mount whose root path is a segment-aligned prefix of + /// `internal_path` within `drive_id`. Returns the config plus the remainder + /// path relative to the mount root (`""` when the path IS the mount root). + /// + /// Used by path-based resolution (WebDAV / NextCloud) in P3. + pub fn find_mount_for_path( + &self, + drive_id: Uuid, + internal_path: &str, + ) -> Option<(Arc, String)> { + let index = self.inner.load(); + // Walk ancestor paths from the full path up to the root, longest first, + // so the deepest matching mount wins. + let mut candidate = internal_path; + loop { + if let Some(mount_id) = index.by_path.get(&(drive_id, candidate.to_string())) + && let Some(cfg) = index.by_folder.get(mount_id) + { + let remainder = internal_path + .strip_prefix(candidate) + .map(|r| r.trim_start_matches('/').to_string()) + .unwrap_or_default(); + return Some((cfg.clone(), remainder)); + } + match candidate.rsplit_once('/') { + Some((parent, _)) => candidate = parent, + None => return None, + } + } + } + + /// Rebuild the registry from persisted records, constructing each provider + /// via the factory. A mount whose provider fails to build is skipped (logged) + /// rather than failing the whole reload. + pub async fn reload( + &self, + repo: &dyn ExternalMountRepositoryPort, + factory: &dyn MountProviderFactory, + ) { + let records = match repo.list_all().await { + Ok(r) => r, + Err(e) => { + tracing::error!( + target: "oxicloud::external_mounts", + "failed to load external mounts: {e}" + ); + return; + } + }; + + let mut by_folder = HashMap::with_capacity(records.len()); + let mut by_path = HashMap::with_capacity(records.len()); + for rec in records { + let provider = match factory.build(&rec.kind, &rec.config).await { + Ok(p) => p, + Err(e) => { + tracing::error!( + target: "oxicloud::external_mounts", + mount_id = %rec.mount_folder_id, + kind = %rec.kind, + "skipping mount: provider build failed: {e}" + ); + continue; + } + }; + by_path.insert((rec.drive_id, rec.mount_path.clone()), rec.mount_folder_id); + by_folder.insert( + rec.mount_folder_id, + Arc::new(MountConfig { + mount_id: rec.mount_folder_id, + kind: rec.kind, + name: rec.name, + owner_id: rec.owner_id, + drive_id: rec.drive_id, + read_only: rec.read_only, + mount_path: rec.mount_path, + provider, + }), + ); + } + + let count = by_folder.len(); + self.inner + .store(Arc::new(MountIndex { by_folder, by_path })); + tracing::info!( + target: "oxicloud::external_mounts", + count, "external mount registry loaded" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::ports::external_mount_ports::{ + ExternalMountRecord, ExternalMountRepositoryPort, + }; + use crate::domain::errors::DomainError; + use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory; + use async_trait::async_trait; + use std::path::Path; + use tempfile::TempDir; + + struct FakeRepo { + records: Vec, + } + + #[async_trait] + impl ExternalMountRepositoryPort for FakeRepo { + async fn list_all(&self) -> Result, DomainError> { + Ok(self.records.clone()) + } + } + + fn record( + mount_id: Uuid, + drive_id: Uuid, + mount_path: &str, + path: &Path, + ) -> ExternalMountRecord { + ExternalMountRecord { + mount_folder_id: mount_id, + kind: "local_fs".to_string(), + config: serde_json::json!({ "path": path.to_str().unwrap() }), + name: "Test Mount".to_string(), + owner_id: Uuid::new_v4(), + read_only: false, + drive_id, + mount_path: mount_path.to_string(), + } + } + + #[test] + fn empty_registry_is_inert() { + let r = MountRegistry::empty(); + assert!(r.is_empty()); + assert!(!r.is_mount_root(&Uuid::new_v4())); + assert!(r.get(&Uuid::new_v4()).is_none()); + } + + #[tokio::test] + async fn reload_populates_from_records() { + let dir = TempDir::new().unwrap(); + let mount_id = Uuid::new_v4(); + let drive_id = Uuid::new_v4(); + let repo = FakeRepo { + records: vec![record(mount_id, drive_id, "Personal/Media", dir.path())], + }; + let factory = DefaultMountProviderFactory::new(); + + let reg = MountRegistry::empty(); + reg.reload(&repo, &factory).await; + + assert!(!reg.is_empty()); + assert!(reg.is_mount_root(&mount_id)); + let cfg = reg.get(&mount_id).expect("present"); + assert_eq!(cfg.kind, "local_fs"); + assert_eq!(cfg.drive_id, drive_id); + assert_eq!(cfg.mount_path, "Personal/Media"); + } + + #[tokio::test] + async fn reload_skips_mount_whose_provider_fails_to_build() { + let mount_id = Uuid::new_v4(); + // Point at a path that doesn't exist → LocalFsMountProvider::new errors. + let repo = FakeRepo { + records: vec![ExternalMountRecord { + mount_folder_id: mount_id, + kind: "local_fs".to_string(), + config: serde_json::json!({ "path": "/nonexistent/path/xyz-123" }), + name: "Bad".to_string(), + owner_id: Uuid::new_v4(), + read_only: false, + drive_id: Uuid::new_v4(), + mount_path: "Personal/Bad".to_string(), + }], + }; + let factory = DefaultMountProviderFactory::new(); + let reg = MountRegistry::empty(); + reg.reload(&repo, &factory).await; + // The bad mount is skipped, not fatal. + assert!(reg.is_empty()); + assert!(!reg.is_mount_root(&mount_id)); + } + + #[tokio::test] + async fn find_mount_for_path_matches_prefix_and_remainder() { + let dir = TempDir::new().unwrap(); + let mount_id = Uuid::new_v4(); + let drive_id = Uuid::new_v4(); + let repo = FakeRepo { + records: vec![record(mount_id, drive_id, "Personal/Media", dir.path())], + }; + let reg = MountRegistry::empty(); + reg.reload(&repo, &DefaultMountProviderFactory::new()).await; + + // Exact match → empty remainder. + let (cfg, rem) = reg + .find_mount_for_path(drive_id, "Personal/Media") + .expect("exact match"); + assert_eq!(cfg.mount_id, mount_id); + assert_eq!(rem, ""); + + // Nested path → remainder is the suffix. + let (_cfg, rem) = reg + .find_mount_for_path(drive_id, "Personal/Media/docs/a.txt") + .expect("nested match"); + assert_eq!(rem, "docs/a.txt"); + + // Non-matching path within the drive → None. + assert!( + reg.find_mount_for_path(drive_id, "Personal/Other") + .is_none() + ); + + // Same path but a DIFFERENT drive → None (drive-scoped). + assert!( + reg.find_mount_for_path(Uuid::new_v4(), "Personal/Media") + .is_none() + ); + + // A sibling that merely shares a name prefix must NOT match + // (segment-aligned only). + assert!( + reg.find_mount_for_path(drive_id, "Personal/MediaLibrary") + .is_none() + ); + } + + #[tokio::test] + async fn reload_replaces_previous_state() { + let dir = TempDir::new().unwrap(); + let drive = Uuid::new_v4(); + let first = Uuid::new_v4(); + let reg = MountRegistry::empty(); + + reg.reload( + &FakeRepo { + records: vec![record(first, drive, "Personal/A", dir.path())], + }, + &DefaultMountProviderFactory::new(), + ) + .await; + assert!(reg.is_mount_root(&first)); + + // A second reload with a different mount set replaces the first entirely. + let second = Uuid::new_v4(); + reg.reload( + &FakeRepo { + records: vec![record(second, drive, "Personal/B", dir.path())], + }, + &DefaultMountProviderFactory::new(), + ) + .await; + assert!(reg.is_mount_root(&second)); + assert!( + !reg.is_mount_root(&first), + "stale mount must be gone after reload" + ); + } + + #[tokio::test] + async fn find_mount_for_path_deepest_wins() { + let outer = TempDir::new().unwrap(); + let inner = TempDir::new().unwrap(); + let drive_id = Uuid::new_v4(); + let outer_id = Uuid::new_v4(); + let inner_id = Uuid::new_v4(); + let repo = FakeRepo { + records: vec![ + record(outer_id, drive_id, "Personal", outer.path()), + record(inner_id, drive_id, "Personal/Media", inner.path()), + ], + }; + let reg = MountRegistry::empty(); + reg.reload(&repo, &DefaultMountProviderFactory::new()).await; + + // A path under the deeper mount resolves to the deeper mount. + let (cfg, rem) = reg + .find_mount_for_path(drive_id, "Personal/Media/x") + .expect("match"); + assert_eq!(cfg.mount_id, inner_id); + assert_eq!(rem, "x"); + + // A path under the shallower mount (but not the deeper one) resolves + // to the shallower mount. + let (cfg, rem) = reg + .find_mount_for_path(drive_id, "Personal/Other/y") + .expect("match"); + assert_eq!(cfg.mount_id, outer_id); + assert_eq!(rem, "Other/y"); + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 7549927f..631f9884 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -906,6 +906,12 @@ pub struct FeaturesConfig { /// thumbnail through the same WebP pipeline as photos; otherwise videos have /// no thumbnail. Env: `OXICLOUD_ENABLE_VIDEO_THUMBNAILS`. pub enable_video_thumbnails: bool, + /// Expose admin-configured external filesystem mounts (raw host fs, …) as + /// folders inside a user's drive. Contents are read live from the backend + /// and are a deliberately limited, separate storage type (no dedup/sharing/ + /// trash/search). OFF by default — opt-in per deployment. + /// Env: `OXICLOUD_ENABLE_EXTERNAL_MOUNTS`. + pub enable_external_mounts: bool, } impl Default for FeaturesConfig { @@ -921,6 +927,7 @@ impl Default for FeaturesConfig { enable_faces: false, // People/faces (biometric) — opt-in, off by default expose_system_users: true, // Expose OxiCloud users as address book by default enable_video_thumbnails: true, // Video thumbs via ffmpeg (if detected) + enable_external_mounts: false, // External mounts — opt-in, off by default } } } @@ -1488,6 +1495,13 @@ impl AppConfig { config.features.enable_faces = val; } + if let Ok(enable_external_mounts) = + env::var("OXICLOUD_ENABLE_EXTERNAL_MOUNTS").map(|v| v.parse::()) + && let Ok(val) = enable_external_mounts + { + config.features.enable_external_mounts = val; + } + // Faces (People) ONNX runtime + models — operator-provided at runtime. if let Ok(v) = env::var("OXICLOUD_FACES_ORT_DYLIB").or_else(|_| env::var("ORT_DYLIB_PATH")) && !v.is_empty() diff --git a/src/common/di.rs b/src/common/di.rs index c37ae31c..dbc0d373 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -518,11 +518,13 @@ impl AppServiceFactory { plugin_dispatch: Option< Arc, >, + mount_router: Arc, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new( repos.folder_repository.clone(), authz.clone(), + mount_router, )); // Built before the upload/management services so the plugin lifecycle @@ -1160,6 +1162,27 @@ impl AppServiceFactory { let (plugin_dispatch, plugin_management) = self.create_plugin_ports(); // 4. Application services (with trash + authz already wired) + // External mount registry + router. Built before the application + // services because `FolderService` holds the router to branch listing + // onto the provider. The router is always present (an empty registry is + // a cheap no-op); when the feature is enabled we load the configured + // mounts and build their providers up front. The registry is + // interior-mutable (arc-swap), so reloading here is visible to every + // holder of the shared router. + let mount_registry = + Arc::new(crate::application::services::mount_registry::MountRegistry::empty()); + if self.config.features.enable_external_mounts { + let repo = crate::infrastructure::repositories::pg::ExternalMountPgRepository::new( + pool.clone(), + ); + let factory = + crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory::new(); + mount_registry.reload(&repo, &factory).await; + } + let mount_router = Arc::new( + crate::application::services::external_mount_router::MountRouter::new(mount_registry), + ); + let mut apps = self.create_application_services( &core, &repos, @@ -1169,6 +1192,7 @@ impl AppServiceFactory { &storage_usage, content_index.as_ref().map(|(idx, _)| idx.clone()), plugin_dispatch.clone(), + mount_router.clone(), ); // 5. Share service @@ -1441,6 +1465,7 @@ impl AppServiceFactory { locale_registry: self.locale_registry.clone(), db_pool: Some(pool.clone()), maintenance_pool: Some(maintenance_pool), + mount_router, auth_service: auth_services, nextcloud: nextcloud_services, admin_settings_service: None, @@ -1894,6 +1919,13 @@ pub struct AppState { pub db_pool: Option>, /// Isolated pool for background / batch operations. pub maintenance_pool: Option>, + /// External-mount classifier + registry. Always present; an empty registry + /// (feature disabled or no mounts configured) makes `classify` a cheap no-op + /// that routes every id to native handling. Handlers consult this before + /// parsing an id as a UUID, then call the matching service-layer mount + /// method (which still owns the authorization check). + pub mount_router: + Arc, pub auth_service: Option, pub nextcloud: Option, pub admin_settings_service: Option>, diff --git a/src/domain/services/external_mount_id.rs b/src/domain/services/external_mount_id.rs new file mode 100644 index 00000000..8f868e49 --- /dev/null +++ b/src/domain/services/external_mount_id.rs @@ -0,0 +1,258 @@ +//! External mount identifiers (domain value objects). +//! +//! Files and folders *below* an external mount root have no database row. They +//! are addressed by a synthetic id that wraps a provider-owned node identity: +//! +//! ```text +//! ext:: +//! ``` +//! +//! * `` is the mount-root folder's UUID (simple form, no hyphens) — the +//! only part the system interprets, used to find the provider in the registry. +//! * `` is **assigned and owned by the provider** and is **opaque** to the +//! rest of the system. Most providers use the entry's path (`local_fs`, `sftp`, +//! `webdav`); a provider with a stronger stable handle (inode, object id, href) +//! may use that. It is base64url-encoded (no padding) so it survives URLs and +//! WebDAV hrefs and never collides with the `:` / `/` separators. +//! +//! The system NEVER parses or validates a `node_id` — it only splits the envelope +//! and hands the decoded bytes back to the provider verbatim. + +use std::fmt; +use std::str::FromStr; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use uuid::Uuid; + +/// The `ext:` scheme prefix that marks a synthetic external-mount id. +pub const EXTERNAL_ID_PREFIX: &str = "ext:"; + +/// A provider-owned, opaque node identity for one entry inside a mount. +/// +/// The system treats this as opaque bytes. For path-based providers it is the +/// POSIX path relative to the mount root (no leading `/`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct NodeId(pub String); + +impl NodeId { + /// Borrow the inner string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume into the inner string. + pub fn into_string(self) -> String { + self.0 + } +} + +impl From for NodeId { + fn from(s: String) -> Self { + NodeId(s) + } +} + +impl From<&str> for NodeId { + fn from(s: &str) -> Self { + NodeId(s.to_owned()) + } +} + +impl fmt::Display for NodeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// The decoded parts of a synthetic external-mount child id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MountChildId { + /// The mount-root folder UUID (envelope; system-interpreted). + pub mount_id: Uuid, + /// The provider-owned node identity (opaque payload). + pub node_id: NodeId, +} + +impl MountChildId { + /// Build a child id from a mount root and a provider node id. + pub fn new(mount_id: Uuid, node_id: impl Into) -> Self { + Self { + mount_id, + node_id: node_id.into(), + } + } +} + +/// Renders as `ext::`. +impl fmt::Display for MountChildId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{EXTERNAL_ID_PREFIX}{}:{}", + self.mount_id.simple(), + URL_SAFE_NO_PAD.encode(self.node_id.0.as_bytes()) + ) + } +} + +/// Error returned when a string is not a well-formed external-mount child id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseMountChildIdError; + +impl fmt::Display for ParseMountChildIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("not a valid external mount id (expected ext::)") + } +} + +impl std::error::Error for ParseMountChildIdError {} + +impl FromStr for MountChildId { + type Err = ParseMountChildIdError; + + fn from_str(s: &str) -> Result { + // ext:: — split into exactly three logical parts. + let rest = s + .strip_prefix(EXTERNAL_ID_PREFIX) + .ok_or(ParseMountChildIdError)?; + let (mount_part, token) = rest.split_once(':').ok_or(ParseMountChildIdError)?; + let mount_id = Uuid::parse_str(mount_part).map_err(|_| ParseMountChildIdError)?; + let bytes = URL_SAFE_NO_PAD + .decode(token) + .map_err(|_| ParseMountChildIdError)?; + let node = String::from_utf8(bytes).map_err(|_| ParseMountChildIdError)?; + Ok(MountChildId { + mount_id, + node_id: NodeId(node), + }) + } +} + +/// Cheap check: does this id use the external-mount scheme? +/// +/// Used at the top of service methods to route `ext:` ids away from +/// `Uuid::parse_str` and the PostgreSQL repositories. +pub fn is_external_id(id: &str) -> bool { + id.starts_with(EXTERNAL_ID_PREFIX) +} + +/// Encode a child id string from its parts. +pub fn encode_child_id(mount_id: Uuid, node_id: impl Into) -> String { + MountChildId::new(mount_id, node_id).to_string() +} + +/// Parse a child id string into its parts, or `None` when not `ext:`-shaped. +pub fn parse_child_id(id: &str) -> Option { + id.parse().ok() +} + +/// ETag for a virtual file (no blob hash): `ext-{size:x}-{modified_at}`. +/// +/// Combines size and mtime so it changes on any content edit without reading +/// the file. Mirrors the native `{blob_hash[..16]}-{modified_at}` shape closely +/// enough for conditional requests. +pub fn virtual_file_etag(size: u64, modified_at: u64) -> String { + format!("ext-{size:x}-{modified_at}") +} + +/// ETag for a virtual folder: `ext-{modified_at}` (directory mtime). +pub fn virtual_folder_etag(modified_at: u64) -> String { + format!("ext-{modified_at}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_simple_path() { + let mount = Uuid::new_v4(); + let id = encode_child_id(mount, "docs/report.txt"); + assert!(is_external_id(&id)); + let parsed = parse_child_id(&id).expect("parse"); + assert_eq!(parsed.mount_id, mount); + assert_eq!(parsed.node_id.as_str(), "docs/report.txt"); + } + + #[test] + fn round_trips_via_fromstr_display() { + let mount = Uuid::new_v4(); + let child = MountChildId::new(mount, "a/b/c.bin"); + let rendered = child.to_string(); + let parsed: MountChildId = rendered.parse().expect("parse"); + assert_eq!(child, parsed); + } + + #[test] + fn token_survives_separators_and_unicode() { + // node ids containing ':' '/' and non-ascii must survive the envelope. + let mount = Uuid::new_v4(); + let tricky = "weird: name/with:colons/café.txt"; + let id = encode_child_id(mount, tricky); + // The encoded form must not be ambiguous to the splitter: exactly two + // colons (the `ext:` scheme and the `:` separator); the + // base64url token never contains `:` or `/`. + assert_eq!(id.matches(':').count(), 2, "only scheme + mount separators"); + let parsed = parse_child_id(&id).expect("parse"); + assert_eq!(parsed.node_id.as_str(), tricky); + } + + #[test] + fn rejects_non_external_ids() { + assert!(parse_child_id("not-an-ext-id").is_none()); + assert!(parse_child_id(&Uuid::new_v4().to_string()).is_none()); + assert!(parse_child_id("ext:not-a-uuid:dG9rZW4").is_none()); + assert!(!is_external_id(&Uuid::new_v4().to_string())); + } + + #[test] + fn rejects_malformed_envelopes() { + // Missing the second colon (no token separator). + assert!(parse_child_id("ext:abc").is_none()); + // `ext:` with a valid uuid but a non-base64url token. + let u = Uuid::new_v4().simple().to_string(); + assert!(parse_child_id(&format!("ext:{u}:!!!not-base64!!!")).is_none()); + // Empty string / bare scheme. + assert!(parse_child_id("").is_none()); + assert!(parse_child_id("ext:").is_none()); + // is_external_id is a pure prefix check. + assert!(is_external_id("ext:anything")); + assert!(!is_external_id("EXT:upper")); + } + + #[test] + fn round_trips_empty_node_id() { + // The mount root's own node id is empty; it must still round-trip + // (e.g. if ever encoded), encoding to a token-less-but-present form. + let mount = Uuid::new_v4(); + let id = encode_child_id(mount, ""); + let parsed = parse_child_id(&id).expect("parse empty node"); + assert_eq!(parsed.mount_id, mount); + assert_eq!(parsed.node_id.as_str(), ""); + } + + #[test] + fn round_trips_long_and_nested_paths() { + let mount = Uuid::new_v4(); + let deep = "a/".repeat(64) + "leaf.bin"; + let id = encode_child_id(mount, deep.clone()); + assert_eq!(parse_child_id(&id).unwrap().node_id.as_str(), deep); + } + + #[test] + fn node_id_conversions() { + assert_eq!(NodeId::from("x").as_str(), "x"); + assert_eq!(NodeId::from(String::from("y")).into_string(), "y"); + assert_eq!(NodeId::default().as_str(), ""); + } + + #[test] + fn etags_change_with_inputs() { + assert_ne!(virtual_file_etag(10, 100), virtual_file_etag(11, 100)); + assert_ne!(virtual_file_etag(10, 100), virtual_file_etag(10, 101)); + assert_ne!(virtual_folder_etag(100), virtual_folder_etag(101)); + // Format is stable and documented. + assert_eq!(virtual_file_etag(255, 16), "ext-ff-16"); + assert_eq!(virtual_folder_etag(42), "ext-42"); + } +} diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index 42855fe5..dce4a86d 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -1,5 +1,6 @@ pub mod authorization; pub mod email_normalize; +pub mod external_mount_id; pub mod i18n_service; pub mod path_service; diff --git a/src/infrastructure/repositories/pg/external_mount_repository.rs b/src/infrastructure/repositories/pg/external_mount_repository.rs new file mode 100644 index 00000000..a18ae48f --- /dev/null +++ b/src/infrastructure/repositories/pg/external_mount_repository.rs @@ -0,0 +1,154 @@ +//! PostgreSQL persistence for external mount configuration. +//! +//! P1 only needs [`ExternalMountRepositoryPort::list_all`] to (re)build the +//! in-memory registry; admin CRUD lands with P4. + +use std::sync::Arc; + +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::application::ports::external_mount_ports::{ + ExternalMountRecord, ExternalMountRepositoryPort, +}; +use crate::domain::errors::DomainError; + +/// PostgreSQL implementation of [`ExternalMountRepositoryPort`]. +pub struct ExternalMountPgRepository { + pool: Arc, +} + +impl ExternalMountPgRepository { + /// Construct over a connection pool. + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ExternalMountRepositoryPort for ExternalMountPgRepository { + async fn list_all(&self) -> Result, DomainError> { + // Join each mount to its (non-trashed) root folder to pick up the + // drive scope and the materialized path needed for path resolution. + let rows = sqlx::query( + r#" + SELECT + m.mount_folder_id AS mount_folder_id, + m.kind AS kind, + m.config AS config, + m.name AS name, + m.owner_id AS owner_id, + m.read_only AS read_only, + f.drive_id AS drive_id, + f.path AS mount_path + FROM storage.external_mounts m + JOIN storage.folders f ON f.id = m.mount_folder_id + WHERE NOT f.is_trashed + "#, + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::database_error(format!("failed to list external mounts: {e}")))?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let drive_id: Option = row + .try_get("drive_id") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?; + let Some(drive_id) = drive_id else { + // A mount root without a drive shouldn't exist post-D0; skip safely. + tracing::warn!( + target: "oxicloud::external_mounts", + "skipping external mount with NULL drive_id" + ); + continue; + }; + out.push(ExternalMountRecord { + mount_folder_id: row + .try_get("mount_folder_id") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + kind: row + .try_get("kind") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + config: row + .try_get("config") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + name: row + .try_get("name") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + owner_id: row + .try_get("owner_id") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + read_only: row + .try_get("read_only") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + drive_id, + mount_path: row + .try_get("mount_path") + .map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?, + }); + } + Ok(out) + } +} + +// Gated on `test` too: the module uses the `testcontainers` dev-dependency, +// which is only linked into test targets — a plain `--cfg integration_tests` +// lib build (e.g. clippy's lib pass) must not try to compile it. +#[cfg(all(test, integration_tests))] +mod integration_tests { + use super::*; + use crate::mount_it_support::{fresh_db, insert_mount, provision_folder}; + + #[tokio::test] + async fn list_all_returns_mount_joined_with_folder() { + let (_c, pool) = fresh_db().await; + let p = provision_folder(&pool, "mountowner", "Media").await; + insert_mount(&pool, &p, "/srv/media").await; + + let repo = ExternalMountPgRepository::new(pool.clone()); + let mounts = repo.list_all().await.expect("list_all"); + + assert_eq!(mounts.len(), 1); + let m = &mounts[0]; + assert_eq!(m.mount_folder_id, p.mount_folder_id); + assert_eq!(m.kind, "local_fs"); + assert_eq!(m.owner_id, p.owner_id); + assert_eq!(m.drive_id, p.drive_id); + assert!(!m.read_only); + // The joined folder path (drive-scoped materialized path) contains the + // mount folder's name. + assert!( + m.mount_path.contains("Media"), + "mount_path was {:?}", + m.mount_path + ); + assert_eq!(m.config["path"], "/srv/media"); + } + + #[tokio::test] + async fn list_all_skips_trashed_mount_folder() { + let (_c, pool) = fresh_db().await; + let p = provision_folder(&pool, "mountowner", "Media").await; + insert_mount(&pool, &p, "/srv/media").await; + + // Soft-delete the mount-root folder; the join filters NOT is_trashed. + sqlx::query("UPDATE storage.folders SET is_trashed = true WHERE id = $1") + .bind(p.mount_folder_id) + .execute(pool.as_ref()) + .await + .unwrap(); + + let repo = ExternalMountPgRepository::new(pool.clone()); + let mounts = repo.list_all().await.expect("list_all"); + assert!(mounts.is_empty()); + } + + #[tokio::test] + async fn list_all_empty_when_no_mounts() { + let (_c, pool) = fresh_db().await; + let repo = ExternalMountPgRepository::new(pool.clone()); + assert!(repo.list_all().await.expect("list_all").is_empty()); + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index ecfc3774..979beba5 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -7,6 +7,7 @@ mod contact_persistence_dto; mod contact_pg_repository; mod device_code_pg_repository; mod drive_pg_repository; +mod external_mount_repository; mod face_pg_repository; mod favorites_pg_repository; pub mod file_metadata_repository; @@ -36,6 +37,7 @@ pub use contact_persistence_dto::*; pub use contact_pg_repository::ContactPgRepository; pub use device_code_pg_repository::DeviceCodePgRepository; pub use drive_pg_repository::DrivePgRepository; +pub use external_mount_repository::ExternalMountPgRepository; pub use face_pg_repository::FacePgRepository; pub use favorites_pg_repository::FavoritesPgRepository; pub use file_blob_read_repository::FileBlobReadRepository; diff --git a/src/infrastructure/services/local_fs_mount_provider.rs b/src/infrastructure/services/local_fs_mount_provider.rs new file mode 100644 index 00000000..ca8316bb --- /dev/null +++ b/src/infrastructure/services/local_fs_mount_provider.rs @@ -0,0 +1,1020 @@ +//! `local_fs` external mount provider — serves a raw host filesystem directory. +//! +//! Bound to one canonicalized `host_path` at construction. `node_id` is the POSIX +//! path relative to that root (so `resolve_path` is identity and ids are NOT +//! stable across renames). Every op funnels through [`LocalFsMountProvider::resolve_existing`] +//! / [`LocalFsMountProvider::resolve_parent`], which reject path traversal and +//! symlink escape by canonicalizing and asserting the result stays under the +//! bound root. + +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::StreamExt; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio_util::io::ReaderStream; + +use crate::application::ports::blob_storage_ports::BlobStream; +use crate::application::ports::external_mount_ports::{ + ExternalMountProvider, MountByteStream, MountCaps, MountEntry, MountStat, +}; +use crate::domain::errors::DomainError; +use crate::domain::services::external_mount_id::NodeId; +use crate::domain::services::path_service::validate_storage_name; + +/// Read buffer size for streamed file reads (mirrors the blob backend). +const STREAM_CHUNK_SIZE: usize = 256 * 1024; + +/// Directories larger than this refuse to list (bounded in-memory sort/cursor). +const MAX_DIR_ENTRIES: usize = 50_000; + +/// A mount provider backed by a local filesystem directory. +pub struct LocalFsMountProvider { + /// Canonical absolute path of the mount root; the containment anchor. + host_path: PathBuf, + /// Whether mutations are refused. + read_only: bool, +} + +impl LocalFsMountProvider { + /// Construct from a host directory path. The path must exist and be a + /// directory; it is canonicalized once so symlink-containment checks are + /// cheap thereafter. + pub fn new(path: impl AsRef, read_only: bool) -> Result { + let host_path = std::fs::canonicalize(path.as_ref()).map_err(|e| { + DomainError::internal_error( + "ExternalMount", + format!( + "mount host path {} is not accessible: {e}", + path.as_ref().display() + ), + ) + })?; + if !host_path.is_dir() { + return Err(DomainError::internal_error( + "ExternalMount", + format!("mount host path {} is not a directory", host_path.display()), + )); + } + Ok(Self { + host_path, + read_only, + }) + } + + /// Lexically join a relative node id onto the host root, rejecting any + /// traversal, absolute, or otherwise unsafe component. Does NOT touch disk. + fn lexical_path(&self, relpath: &str) -> Result { + if relpath.is_empty() { + return Ok(self.host_path.clone()); + } + if relpath.starts_with('/') || relpath.contains('\0') || relpath.contains('\\') { + return Err(DomainError::not_found("ExternalMount", relpath)); + } + let mut out = self.host_path.clone(); + for segment in relpath.split('/') { + // Reject empty / "." / ".." and re-use the storage-name validator + // (no slashes, no null, not "."/".."). + if validate_storage_name(segment).is_err() { + return Err(DomainError::not_found("ExternalMount", relpath)); + } + out.push(segment); + } + Ok(out) + } + + /// Assert `candidate` (once canonicalized) stays under the host root. + /// Returns the canonical path to use for the actual op (so we never follow + /// an escaping symlink). + async fn assert_within(&self, candidate: &Path, relpath: &str) -> Result { + let canonical = tokio::fs::canonicalize(candidate) + .await + .map_err(|e| map_io_err(e, relpath))?; + if !canonical.starts_with(&self.host_path) { + // Symlink escape or traversal — treat as not found (anti-enumeration). + return Err(DomainError::not_found("ExternalMount", relpath)); + } + Ok(canonical) + } + + /// Resolve an existing entry to its canonical on-disk path. + async fn resolve_existing(&self, node: &NodeId) -> Result { + let lexical = self.lexical_path(node.as_str())?; + self.assert_within(&lexical, node.as_str()).await + } + + /// Resolve a parent directory (must exist) to its canonical path, for + /// create/write ops whose final target does not exist yet. + async fn resolve_parent(&self, parent: &NodeId) -> Result { + let lexical = self.lexical_path(parent.as_str())?; + let canonical = self.assert_within(&lexical, parent.as_str()).await?; + if !canonical.is_dir() { + return Err(DomainError::not_found("ExternalMount", parent.as_str())); + } + Ok(canonical) + } + + /// Refuse mutations on read-only mounts. + fn ensure_writable(&self) -> Result<(), DomainError> { + if self.read_only { + return Err(DomainError::operation_not_supported( + "ExternalMount", + "mount is read-only", + )); + } + Ok(()) + } + + /// Refuse operations that target the mount root itself (empty node id). + /// + /// Without this, `delete("")` would `remove_dir_all` the entire mount root + /// and `rename("", …)` would move the root *outside* the containment anchor + /// (its parent directory lives outside the mount). The mount root is managed + /// as a `storage.folders` row, never through the provider. + fn ensure_not_root(node: &NodeId) -> Result<(), DomainError> { + if node.as_str().is_empty() { + return Err(DomainError::operation_not_supported( + "ExternalMount", + "the mount root itself cannot be modified through the provider", + )); + } + Ok(()) + } + + /// Build a child node id given a parent relpath and child name. + fn child_node_id(parent_relpath: &str, name: &str) -> NodeId { + if parent_relpath.is_empty() { + NodeId(name.to_string()) + } else { + NodeId(format!("{parent_relpath}/{name}")) + } + } + + /// Turn a path + its metadata into a [`MountStat`]. + fn stat_from_meta(node_id: NodeId, name: &str, meta: &std::fs::Metadata) -> MountStat { + let is_dir = meta.is_dir(); + MountStat { + node_id, + is_dir, + size: if is_dir { 0 } else { meta.len() }, + modified_at: system_time_secs(meta.modified().ok()), + created_at: system_time_secs(meta.created().ok().or_else(|| meta.modified().ok())), + mime_type: if is_dir { + "directory".to_string() + } else { + mime_guess::from_path(name) + .first_or_octet_stream() + .to_string() + }, + } + } +} + +/// Map a `std::io::Error` to a `DomainError`, preserving anti-enumeration for +/// not-found and a stable shape for the rest. +fn map_io_err(e: std::io::Error, relpath: &str) -> DomainError { + use std::io::ErrorKind as Io; + match e.kind() { + Io::NotFound => DomainError::not_found("ExternalMount", relpath), + Io::PermissionDenied => { + DomainError::access_denied("ExternalMount", format!("permission denied: {relpath}")) + } + Io::AlreadyExists => DomainError::already_exists("ExternalMount", relpath), + _ => DomainError::internal_error("ExternalMount", format!("io error on {relpath}: {e}")), + } +} + +/// Convert an optional `SystemTime` to unix seconds (0 when unavailable). +fn system_time_secs(t: Option) -> u64 { + t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Final path segment of a node id, for display + mime sniffing. +fn node_name(relpath: &str) -> &str { + relpath.rsplit('/').next().unwrap_or(relpath) +} + +#[async_trait] +impl ExternalMountProvider for LocalFsMountProvider { + fn kind(&self) -> &'static str { + "local_fs" + } + + fn capabilities(&self) -> MountCaps { + MountCaps { + supports_range: true, + read_only: self.read_only, + stable_ids: false, + } + } + + // resolve_path uses the default identity impl (node_id == relpath). + + async fn list_dir(&self, node_id: &NodeId) -> Result, DomainError> { + let dir = self.resolve_existing(node_id).await?; + let parent_rel = node_id.as_str(); + + let mut read_dir = tokio::fs::read_dir(&dir) + .await + .map_err(|e| map_io_err(e, parent_rel))?; + + let mut entries = Vec::new(); + while let Some(dirent) = read_dir + .next_entry() + .await + .map_err(|e| map_io_err(e, parent_rel))? + { + // Reject any non-UTF8 / unsafe name (cannot round-trip through ids). + let os_name = dirent.file_name(); + let Some(name) = os_name.to_str() else { + continue; + }; + if validate_storage_name(name).is_err() { + continue; + } + // Skip symlinks that escape the mount root (containment check). + let meta = match dirent.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + if meta.file_type().is_symlink() { + let child_lex = dir.join(name); + if self.assert_within(&child_lex, name).await.is_err() { + continue; + } + } + let node_id = Self::child_node_id(parent_rel, name); + let is_dir = meta.is_dir(); + entries.push(MountEntry { + name: name.to_string(), + node_id, + is_dir, + size: if is_dir { 0 } else { meta.len() }, + modified_at: system_time_secs(meta.modified().ok()), + created_at: system_time_secs(meta.created().ok().or_else(|| meta.modified().ok())), + }); + + if entries.len() > MAX_DIR_ENTRIES { + return Err(DomainError::operation_not_supported( + "ExternalMount", + format!("directory exceeds {MAX_DIR_ENTRIES} entries"), + )); + } + } + Ok(entries) + } + + async fn stat(&self, node_id: &NodeId) -> Result { + let path = self.resolve_existing(node_id).await?; + let meta = tokio::fs::metadata(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + Ok(Self::stat_from_meta( + node_id.clone(), + node_name(node_id.as_str()), + &meta, + )) + } + + async fn open_read_stream( + &self, + node_id: &NodeId, + range: Option<(u64, Option)>, + ) -> Result { + let path = self.resolve_existing(node_id).await?; + let mut file = tokio::fs::File::open(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + + match range { + Some((start, end_inclusive)) => { + file.seek(SeekFrom::Start(start)) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + if let Some(end) = end_inclusive { + // HTTP range end is inclusive. + let limit = end.saturating_sub(start).saturating_add(1); + let limited = file.take(limit); + Ok( + Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE)) + as BlobStream, + ) + } else { + Ok( + Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)) + as BlobStream, + ) + } + } + None => { + Ok(Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)) as BlobStream) + } + } + } + + async fn create_dir(&self, parent: &NodeId, name: &str) -> Result { + self.ensure_writable()?; + validate_name(name)?; + let parent_path = self.resolve_parent(parent).await?; + let target = parent_path.join(name); + tokio::fs::create_dir(&target) + .await + .map_err(|e| map_io_err(e, name))?; + let node_id = Self::child_node_id(parent.as_str(), name); + self.stat(&node_id).await + } + + async fn write_stream( + &self, + parent: &NodeId, + name: &str, + mut body: MountByteStream, + ) -> Result { + self.ensure_writable()?; + validate_name(name)?; + let parent_path = self.resolve_parent(parent).await?; + let target = parent_path.join(name); + + // Stream to disk; on ANY error (create / stream / write / flush) the + // partial file is removed so a retry starts from a clean slate. + let write_result = async { + let mut file = tokio::fs::File::create(&target) + .await + .map_err(|e| map_io_err(e, name))?; + while let Some(chunk) = body.next().await { + let bytes: Bytes = chunk.map_err(|e| { + DomainError::internal_error( + "ExternalMount", + format!("upload stream error: {e}"), + ) + })?; + file.write_all(&bytes) + .await + .map_err(|e| map_io_err(e, name))?; + } + file.flush().await.map_err(|e| map_io_err(e, name))?; + Ok::<(), DomainError>(()) + } + .await; + + if let Err(e) = write_result { + let _ = tokio::fs::remove_file(&target).await; + return Err(e); + } + + let node_id = Self::child_node_id(parent.as_str(), name); + self.stat(&node_id).await + } + + async fn rename(&self, node_id: &NodeId, new_name: &str) -> Result { + self.ensure_writable()?; + Self::ensure_not_root(node_id)?; + validate_name(new_name)?; + let from = self.resolve_existing(node_id).await?; + let parent = from.parent().ok_or_else(|| { + DomainError::operation_not_supported("ExternalMount", "cannot rename mount root") + })?; + let to = parent.join(new_name); + tokio::fs::rename(&from, &to) + .await + .map_err(|e| map_io_err(e, new_name))?; + // Recompute the node id for the new name (same parent). + let parent_rel = parent_relpath(node_id.as_str()); + let new_node = Self::child_node_id(parent_rel, new_name); + self.stat(&new_node).await + } + + async fn delete(&self, node_id: &NodeId) -> Result<(), DomainError> { + self.ensure_writable()?; + Self::ensure_not_root(node_id)?; + let path = self.resolve_existing(node_id).await?; + let meta = tokio::fs::metadata(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + if meta.is_dir() { + tokio::fs::remove_dir_all(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str())) + } else { + tokio::fs::remove_file(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str())) + } + } + + async fn move_within( + &self, + node_id: &NodeId, + dest_parent: &NodeId, + ) -> Result { + self.ensure_writable()?; + Self::ensure_not_root(node_id)?; + let from = self.resolve_existing(node_id).await?; + let name = node_name(node_id.as_str()).to_string(); + let dest_dir = self.resolve_parent(dest_parent).await?; + let to = dest_dir.join(&name); + tokio::fs::rename(&from, &to) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + let new_node = Self::child_node_id(dest_parent.as_str(), &name); + self.stat(&new_node).await + } +} + +/// Validate a single new name component (mkdir / upload / rename target). +fn validate_name(name: &str) -> Result<(), DomainError> { + validate_storage_name(name) + .map_err(|reason| DomainError::validation_error(format!("invalid name '{name}': {reason}"))) +} + +/// Parent relpath of a node id (`""` when the node is a direct child of root). +fn parent_relpath(relpath: &str) -> &str { + match relpath.rsplit_once('/') { + Some((parent, _)) => parent, + None => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn provider(dir: &Path) -> LocalFsMountProvider { + LocalFsMountProvider::new(dir, false).expect("provider") + } + + #[tokio::test] + async fn lists_and_stats_real_files() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + std::fs::write(dir.path().join("a.txt"), b"hello").unwrap(); + let p = provider(dir.path()); + + let mut entries = p.list_dir(&NodeId("".into())).await.unwrap(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "a.txt"); + assert!(!entries[0].is_dir); + assert_eq!(entries[0].size, 5); + assert_eq!(entries[1].name, "sub"); + assert!(entries[1].is_dir); + + let stat = p.stat(&NodeId("a.txt".into())).await.unwrap(); + assert_eq!(stat.size, 5); + assert!(!stat.is_dir); + } + + #[tokio::test] + async fn rejects_traversal_and_absolute() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + assert!(p.stat(&NodeId("../escape".into())).await.is_err()); + assert!(p.stat(&NodeId("/etc/passwd".into())).await.is_err()); + assert!(p.stat(&NodeId("a/../../b".into())).await.is_err()); + } + + #[cfg(unix)] + #[tokio::test] + async fn rejects_symlink_escape() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"top secret").unwrap(); + std::os::unix::fs::symlink(outside.path().join("secret.txt"), dir.path().join("link")) + .unwrap(); + let p = provider(dir.path()); + // Stat through the escaping symlink must be rejected... + assert!(p.stat(&NodeId("link".into())).await.is_err()); + // ...and it must not appear in listings. + let entries = p.list_dir(&NodeId("".into())).await.unwrap(); + assert!(entries.iter().all(|e| e.name != "link")); + } + + #[tokio::test] + async fn read_stream_round_trips_and_ranges() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f.bin"), b"0123456789").unwrap(); + let p = provider(dir.path()); + + let full: Vec = p + .open_read_stream(&NodeId("f.bin".into()), None) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(full, b"0123456789"); + + // bytes 2..=5 inclusive => "2345" + let part: Vec = p + .open_read_stream(&NodeId("f.bin".into()), Some((2, Some(5)))) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(part, b"2345"); + } + + #[tokio::test] + async fn write_mkdir_rename_delete_round_trip() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + + // mkdir + let d = p.create_dir(&NodeId("".into()), "folder").await.unwrap(); + assert!(d.is_dir); + + // write into it + let body: MountByteStream = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"data")) })); + let f = p + .write_stream(&NodeId("folder".into()), "x.txt", body) + .await + .unwrap(); + assert_eq!(f.size, 4); + assert_eq!(f.node_id.as_str(), "folder/x.txt"); + + // rename + let r = p + .rename(&NodeId("folder/x.txt".into()), "y.txt") + .await + .unwrap(); + assert_eq!(r.node_id.as_str(), "folder/y.txt"); + + // delete + p.delete(&NodeId("folder/y.txt".into())).await.unwrap(); + assert!(p.stat(&NodeId("folder/y.txt".into())).await.is_err()); + } + + #[tokio::test] + async fn read_only_refuses_mutations() { + let dir = tempdir().unwrap(); + let p = LocalFsMountProvider::new(dir.path(), true).unwrap(); + assert!(p.create_dir(&NodeId("".into()), "nope").await.is_err()); + assert!(p.capabilities().read_only); + // Read paths still work on a read-only mount. + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + assert!(p.stat(&NodeId("a.txt".into())).await.is_ok()); + } + + // ── construction ──────────────────────────────────────────────────────── + + #[tokio::test] + async fn new_rejects_missing_path() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("does-not-exist"); + assert!(LocalFsMountProvider::new(&missing, false).is_err()); + } + + #[tokio::test] + async fn new_rejects_file_as_root() { + let dir = tempdir().unwrap(); + let file = dir.path().join("f"); + std::fs::write(&file, b"x").unwrap(); + assert!(LocalFsMountProvider::new(&file, false).is_err()); + } + + #[test] + fn kind_and_capabilities() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + assert_eq!(p.kind(), "local_fs"); + let caps = p.capabilities(); + assert!(caps.supports_range); + assert!(!caps.read_only); + assert!(!caps.stable_ids); + } + + // ── nested listing + node ids ─────────────────────────────────────────── + + #[tokio::test] + async fn lists_nested_directory_with_relative_node_ids() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("a/b")).unwrap(); + std::fs::write(dir.path().join("a/b/c.txt"), b"hi").unwrap(); + let p = provider(dir.path()); + + let entries = p.list_dir(&NodeId("a/b".into())).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "c.txt"); + // node id is the FULL relative path, not just the name. + assert_eq!(entries[0].node_id.as_str(), "a/b/c.txt"); + + // resolve_path is identity for local_fs. + assert_eq!(p.resolve_path("a/b/c.txt").as_str(), "a/b/c.txt"); + } + + #[tokio::test] + async fn list_dir_skips_unsafe_entry_names() { + // A file literally named ".." can't exist, but a name with a leading + // dot is fine; verify dotfiles ARE listed (only traversal tokens are + // rejected, and the fs never yields "."/".."). + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join(".hidden"), b"x").unwrap(); + let p = provider(dir.path()); + let entries = p.list_dir(&NodeId("".into())).await.unwrap(); + assert!(entries.iter().any(|e| e.name == ".hidden")); + } + + // ── stat error paths ──────────────────────────────────────────────────── + + #[tokio::test] + async fn stat_directory_reports_dir_and_zero_size() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("d")).unwrap(); + let p = provider(dir.path()); + let s = p.stat(&NodeId("d".into())).await.unwrap(); + assert!(s.is_dir); + assert_eq!(s.size, 0); + assert_eq!(s.mime_type, "directory"); + assert_eq!(s.node_id.as_str(), "d"); + } + + #[tokio::test] + async fn stat_missing_is_not_found() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let err = p.stat(&NodeId("nope.txt".into())).await.unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + #[tokio::test] + async fn stat_sniffs_mime_from_extension() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("p.json"), b"{}").unwrap(); + let p = provider(dir.path()); + let s = p.stat(&NodeId("p.json".into())).await.unwrap(); + assert_eq!(s.mime_type, "application/json"); + } + + // ── range edge cases ──────────────────────────────────────────────────── + + #[tokio::test] + async fn range_to_end_when_end_is_none() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f"), b"0123456789").unwrap(); + let p = provider(dir.path()); + let out: Vec = p + .open_read_stream(&NodeId("f".into()), Some((7, None))) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(out, b"789"); + } + + #[tokio::test] + async fn range_end_past_eof_is_clamped_by_fs() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f"), b"abc").unwrap(); + let p = provider(dir.path()); + // end well past EOF — take() simply yields what exists. + let out: Vec = p + .open_read_stream(&NodeId("f".into()), Some((1, Some(99)))) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(out, b"bc"); + } + + #[tokio::test] + async fn open_missing_file_is_not_found() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + // BlobStream is not Debug, so match rather than unwrap_err. + let err = match p.open_read_stream(&NodeId("ghost".into()), None).await { + Ok(_) => panic!("expected an error opening a missing file"), + Err(e) => e, + }; + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + // ── create / write error paths ────────────────────────────────────────── + + #[tokio::test] + async fn create_dir_existing_is_already_exists() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("d")).unwrap(); + let p = provider(dir.path()); + let err = p.create_dir(&NodeId("".into()), "d").await.unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::AlreadyExists); + } + + #[tokio::test] + async fn create_dir_rejects_invalid_name() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + // names with separators / traversal are validation errors. + assert!(p.create_dir(&NodeId("".into()), "a/b").await.is_err()); + assert!(p.create_dir(&NodeId("".into()), "..").await.is_err()); + assert!(p.create_dir(&NodeId("".into()), "").await.is_err()); + } + + #[tokio::test] + async fn create_under_missing_parent_is_not_found() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let err = p + .create_dir(&NodeId("ghost".into()), "child") + .await + .unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + #[tokio::test] + async fn write_overwrites_existing_file() { + use futures::stream; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f.txt"), b"old-and-longer").unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"new")) })); + let s = p + .write_stream(&NodeId("".into()), "f.txt", body) + .await + .unwrap(); + assert_eq!(s.size, 3); + assert_eq!( + std::fs::read(dir.path().join("f.txt")).unwrap(), + b"new".to_vec() + ); + } + + #[tokio::test] + async fn write_multi_chunk_concatenates() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = Box::pin(stream::iter(vec![ + Ok(Bytes::from_static(b"foo")), + Ok(Bytes::from_static(b"bar")), + Ok(Bytes::from_static(b"baz")), + ])); + let s = p + .write_stream(&NodeId("".into()), "m.txt", body) + .await + .unwrap(); + assert_eq!(s.size, 9); + assert_eq!( + std::fs::read(dir.path().join("m.txt")).unwrap(), + b"foobarbaz" + ); + } + + #[tokio::test] + async fn write_mid_stream_error_removes_partial_file() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = Box::pin(stream::iter(vec![ + Ok(Bytes::from_static(b"partial")), + Err(std::io::Error::other("boom")), + ])); + let err = p + .write_stream(&NodeId("".into()), "broken.txt", body) + .await + .unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::InternalError); + // The partial file must not be left behind. + assert!(!dir.path().join("broken.txt").exists()); + } + + // ── root-mutation guard (security) ────────────────────────────────────── + + #[tokio::test] + async fn root_cannot_be_deleted() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("keep.txt"), b"x").unwrap(); + let p = provider(dir.path()); + let err = p.delete(&NodeId("".into())).await.unwrap_err(); + assert_eq!( + err.kind, + crate::domain::errors::ErrorKind::UnsupportedOperation + ); + // The mount root and its contents survive. + assert!(dir.path().exists()); + assert!(dir.path().join("keep.txt").exists()); + } + + #[tokio::test] + async fn root_cannot_be_renamed_or_moved() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("d")).unwrap(); + let p = provider(dir.path()); + assert!(p.rename(&NodeId("".into()), "escaped").await.is_err()); + assert!( + p.move_within(&NodeId("".into()), &NodeId("d".into())) + .await + .is_err() + ); + assert!(dir.path().exists()); + } + + // ── rename / move semantics ───────────────────────────────────────────── + + #[tokio::test] + async fn rename_rejects_invalid_new_name() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let p = provider(dir.path()); + assert!(p.rename(&NodeId("a.txt".into()), "b/c").await.is_err()); + assert!(p.rename(&NodeId("a.txt".into()), "..").await.is_err()); + } + + #[tokio::test] + async fn move_within_relocates_into_subdir() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + std::fs::create_dir(dir.path().join("dest")).unwrap(); + let p = provider(dir.path()); + let s = p + .move_within(&NodeId("a.txt".into()), &NodeId("dest".into())) + .await + .unwrap(); + assert_eq!(s.node_id.as_str(), "dest/a.txt"); + assert!(!dir.path().join("a.txt").exists()); + assert!(dir.path().join("dest/a.txt").exists()); + } + + #[tokio::test] + async fn move_into_missing_dest_is_not_found() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let p = provider(dir.path()); + let err = p + .move_within(&NodeId("a.txt".into()), &NodeId("ghost".into())) + .await + .unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + #[tokio::test] + async fn delete_directory_recursively() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("d/e")).unwrap(); + std::fs::write(dir.path().join("d/e/f.txt"), b"x").unwrap(); + let p = provider(dir.path()); + p.delete(&NodeId("d".into())).await.unwrap(); + assert!(!dir.path().join("d").exists()); + } + + // ── map_io_err mapping table ──────────────────────────────────────────── + + #[test] + fn io_error_mapping() { + use crate::domain::errors::ErrorKind; + use std::io::{Error, ErrorKind as Io}; + assert_eq!( + map_io_err(Error::from(Io::NotFound), "x").kind, + ErrorKind::NotFound + ); + assert_eq!( + map_io_err(Error::from(Io::PermissionDenied), "x").kind, + ErrorKind::AccessDenied + ); + assert_eq!( + map_io_err(Error::from(Io::AlreadyExists), "x").kind, + ErrorKind::AlreadyExists + ); + assert_eq!( + map_io_err(Error::other("weird"), "x").kind, + ErrorKind::InternalError + ); + } + + #[test] + fn parent_relpath_and_node_name_helpers() { + assert_eq!(parent_relpath("a/b/c"), "a/b"); + assert_eq!(parent_relpath("top"), ""); + assert_eq!(node_name("a/b/c.txt"), "c.txt"); + assert_eq!(node_name("solo"), "solo"); + } + #[tokio::test] + async fn lists_empty_directory() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("empty")).unwrap(); + let p = provider(dir.path()); + let entries = p.list_dir(&NodeId("empty".into())).await.unwrap(); + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn list_dir_on_a_file_errors() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f.txt"), b"x").unwrap(); + let p = provider(dir.path()); + assert!(p.list_dir(&NodeId("f.txt".into())).await.is_err()); + } + + #[tokio::test] + async fn stat_root_reports_directory() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let s = p.stat(&NodeId("".into())).await.unwrap(); + assert!(s.is_dir); + assert_eq!(s.node_id.as_str(), ""); + } + + #[tokio::test] + async fn write_empty_file_zero_chunks() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = Box::pin(stream::empty()); + let s = p + .write_stream(&NodeId("".into()), "empty.txt", body) + .await + .unwrap(); + assert_eq!(s.size, 0); + assert!(dir.path().join("empty.txt").exists()); + } + + #[tokio::test] + async fn rename_onto_existing_sibling() { + // POSIX rename replaces an existing file at the destination. + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"aaa").unwrap(); + std::fs::write(dir.path().join("b.txt"), b"b").unwrap(); + let p = provider(dir.path()); + let r = p.rename(&NodeId("a.txt".into()), "b.txt").await.unwrap(); + assert_eq!(r.node_id.as_str(), "b.txt"); + assert_eq!(r.size, 3); + assert!(!dir.path().join("a.txt").exists()); + } + + #[tokio::test] + async fn move_dir_into_own_subtree_is_rejected_by_os() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("d/inner")).unwrap(); + let p = provider(dir.path()); + // Moving "d" into "d/inner" is a cycle; the OS refuses it. + assert!( + p.move_within(&NodeId("d".into()), &NodeId("d/inner".into())) + .await + .is_err() + ); + assert!(dir.path().join("d/inner").exists()); + } + + #[tokio::test] + async fn unicode_filenames_round_trip() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("café.txt"), b"\xc3\xa9").unwrap(); + let p = provider(dir.path()); + let entries = p.list_dir(&NodeId("".into())).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "café.txt"); + assert_eq!(entries[0].node_id.as_str(), "café.txt"); + let bytes: Vec = p + .open_read_stream(&entries[0].node_id, None) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(bytes, b"\xc3\xa9"); + } + + #[tokio::test] + async fn read_only_refuses_all_mutators() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + std::fs::create_dir(dir.path().join("dest")).unwrap(); + let p = LocalFsMountProvider::new(dir.path(), true).unwrap(); + use futures::stream; + let body: MountByteStream = Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) })); + assert!( + p.write_stream(&NodeId("".into()), "n.txt", body) + .await + .is_err() + ); + assert!(p.rename(&NodeId("a.txt".into()), "b.txt").await.is_err()); + assert!(p.delete(&NodeId("a.txt".into())).await.is_err()); + assert!( + p.move_within(&NodeId("a.txt".into()), &NodeId("dest".into())) + .await + .is_err() + ); + // Read paths still work. + assert!(p.stat(&NodeId("a.txt".into())).await.is_ok()); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index cbdf0a9c..4fa4cb60 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -15,11 +15,13 @@ pub mod file_system_i18n_service; pub mod image_transcode_service; pub mod jwt_service; pub mod local_blob_backend; +pub mod local_fs_mount_provider; pub mod login_lockout_service; pub mod media_metadata_service; pub mod migration_blob_backend; pub mod migration_job; pub mod mock_email_sender; +pub mod mount_provider_factory; pub mod nextcloud_chunked_upload_service; pub mod noop_face_analyzer; pub mod oidc_service; diff --git a/src/infrastructure/services/mount_provider_factory.rs b/src/infrastructure/services/mount_provider_factory.rs new file mode 100644 index 00000000..bf2c80f9 --- /dev/null +++ b/src/infrastructure/services/mount_provider_factory.rs @@ -0,0 +1,122 @@ +//! The single place external mount provider kinds are registered. +//! +//! Adding a new backend (`sftp`, `webdav`, …) is: implement +//! [`ExternalMountProvider`](crate::application::ports::external_mount_ports::ExternalMountProvider) +//! and add one arm to [`DefaultMountProviderFactory::build`]. Nothing else in the +//! router / listing / authz / path-resolution layers changes. + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::application::ports::external_mount_ports::{ + ExternalMountProvider, MountProviderFactory, +}; +use crate::domain::errors::DomainError; +use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider; + +/// Default factory: knows the built-in provider kinds. +#[derive(Default)] +pub struct DefaultMountProviderFactory; + +impl DefaultMountProviderFactory { + /// Construct the factory. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl MountProviderFactory for DefaultMountProviderFactory { + async fn build( + &self, + kind: &str, + config: &serde_json::Value, + ) -> Result, DomainError> { + match kind { + "local_fs" => { + let path = config.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + DomainError::validation_error( + "local_fs mount config requires a string \"path\"", + ) + })?; + let read_only = config + .get("read_only") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let provider = LocalFsMountProvider::new(path, read_only)?; + Ok(Arc::new(provider)) + } + other => Err(DomainError::operation_not_supported( + "ExternalMount", + format!("unknown mount provider kind: {other}"), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::errors::{DomainError, ErrorKind}; + + /// `Arc` isn't `Debug`, so `unwrap_err` won't + /// compile — extract the error by matching instead. + fn expect_err(r: Result, DomainError>) -> DomainError { + match r { + Ok(_) => panic!("expected an error"), + Err(e) => e, + } + } + + #[tokio::test] + async fn builds_local_fs_provider_from_valid_config() { + let dir = tempfile::tempdir().unwrap(); + let factory = DefaultMountProviderFactory::new(); + let cfg = serde_json::json!({ "path": dir.path().to_str().unwrap() }); + let provider = factory.build("local_fs", &cfg).await.expect("builds"); + assert_eq!(provider.kind(), "local_fs"); + } + + #[tokio::test] + async fn local_fs_honours_read_only_flag() { + let dir = tempfile::tempdir().unwrap(); + let factory = DefaultMountProviderFactory::new(); + let cfg = serde_json::json!({ "path": dir.path().to_str().unwrap(), "read_only": true }); + let provider = factory.build("local_fs", &cfg).await.unwrap(); + assert!(provider.capabilities().read_only); + } + + #[tokio::test] + async fn unknown_kind_is_unsupported() { + let factory = DefaultMountProviderFactory::new(); + let err = expect_err(factory.build("sftp", &serde_json::json!({})).await); + assert_eq!(err.kind, ErrorKind::UnsupportedOperation); + } + + #[tokio::test] + async fn local_fs_missing_path_is_validation_error() { + let factory = DefaultMountProviderFactory::new(); + let err = expect_err( + factory + .build("local_fs", &serde_json::json!({ "read_only": true })) + .await, + ); + assert_eq!(err.kind, ErrorKind::InvalidInput); + } + + #[tokio::test] + async fn local_fs_nonexistent_path_errors() { + let factory = DefaultMountProviderFactory::new(); + let err = expect_err( + factory + .build( + "local_fs", + &serde_json::json!({ "path": "/no/such/dir/xyz123" }), + ) + .await, + ); + // Propagated from LocalFsMountProvider::new (canonicalize failure). + assert_eq!(err.kind, ErrorKind::InternalError); + } +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 00e6312d..961473e9 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -11,13 +11,18 @@ use serde::Deserialize; use std::collections::HashMap; use utoipa::ToSchema; +use crate::application::ports::external_mount_ports::MountStat; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase}; +use crate::application::services::external_mount_router::ResolvedId; +use crate::application::services::mount_registry::MountConfig; use crate::common::di::AppState; +use crate::domain::errors::DomainError; +use crate::domain::services::external_mount_id::{NodeId, virtual_file_etag}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::range_requests::not_modified_response; @@ -631,6 +636,22 @@ impl FileHandler { Query(params): Query>, headers: HeaderMap, ) -> impl IntoResponse { + // External mount: download a file living on the provider's backend. + // (A mount-root UUID is a folder and is not downloadable — it falls + // through and 404s as a non-file.) + if let ResolvedId::MountChild { cfg, node_id } = state.mount_router.classify(&id) { + return Self::download_mount_file( + &state, + &cfg, + &node_id, + &id, + auth_user.id, + ¶ms, + &headers, + ) + .await; + } + let retrieval = &state.applications.file_retrieval_service; // ── Get file metadata (ownership-scoped) ──────────────────────── @@ -769,6 +790,146 @@ impl FileHandler { } } + /// Download a file living inside an external mount: stat via the provider + /// (authorized against the mount root), then serve metadata / 304 / Range / + /// full stream straight from the backend. No blob cache, dedup, or WebP + /// transcode — mount content is served as-is. + #[allow(clippy::too_many_arguments)] + pub(super) async fn download_mount_file( + state: &AppState, + cfg: &MountConfig, + node_id: &NodeId, + id: &str, + caller_id: uuid::Uuid, + params: &HashMap, + headers: &HeaderMap, + ) -> axum::response::Response { + let retrieval = &state.applications.file_retrieval_service; + + let stat: MountStat = match retrieval + .stat_mount_file_with_perms(cfg, node_id, caller_id) + .await + { + Ok(s) => s, + Err(err) => return AppError::from(err).into_response(), + }; + if stat.is_dir { + // Directories are not downloadable through this endpoint. + return AppError::from(DomainError::not_found("File", id)).into_response(); + } + + let name = node_id + .as_str() + .rsplit('/') + .next() + .unwrap_or_else(|| node_id.as_str()); + + // ── Metadata-only request ──────────────────────────────────── + if params + .get("metadata") + .is_some_and(|v| v == "true" || v == "1") + { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "id": id, + "name": name, + "size": stat.size, + "mime_type": stat.mime_type, + "modified_at": stat.modified_at, + })), + ) + .into_response(); + } + + let etag = format!("\"{}\"", virtual_file_etag(stat.size, stat.modified_at)); + if let Some(resp) = not_modified_response(headers, &etag) { + return resp.into_response(); + } + + // ── Range Requests ─────────────────────────────────────────── + if let Some(range_header) = headers.get(header::RANGE) + && let Ok(range_str) = range_header.to_str() + && let Ok(ranges) = parse_range_header(range_str) + { + match ranges.validate(stat.size) { + Ok(valid_ranges) => { + if let Some(range) = valid_ranges.first() { + let start = *range.start(); + let end = *range.end(); + let range_length = end - start + 1; + let disposition = Self::content_disposition(name, &stat.mime_type, params); + match retrieval + .open_mount_file_with_perms( + cfg, + node_id, + caller_id, + Some((start, Some(end))), + ) + .await + { + Ok(stream) => { + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, &stat.mime_type) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, range_length) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, stat.size), + ) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .body(Body::from_stream(stream)) + .unwrap() + .into_response(); + } + Err(err) => { + tracing::error!("Error creating mount range stream: {}", err); + // fall through to full download + } + } + } + } + Err(_) => { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", stat.size)) + .body(Body::empty()) + .unwrap() + .into_response(); + } + } + } + + // ── Normal download ────────────────────────────────────────── + let disposition = Self::content_disposition(name, &stat.mime_type, params); + match retrieval + .open_mount_file_with_perms(cfg, node_id, caller_id, None) + .await + { + Ok(stream) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &stat.mime_type) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, stat.size) + .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::from_stream(stream)) + .unwrap() + .into_response(), + Err(err) => AppError::from(err).into_response(), + } + } + // ═══════════════════════════════════════════════════════════════════════ // LIST // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 7dc304df..e67a8ab9 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -17,11 +17,17 @@ use crate::application::dtos::folder_dto::{ ListResourcesOptions, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::application::ports::external_mount_ports::MountEntry; use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; +use crate::application::services::external_mount_router::ResolvedId; use crate::application::services::folder_service::FolderService; +use crate::application::services::mount_registry::MountConfig; use crate::common::di::AppState as GlobalAppState; use crate::domain::entities::file::File; +use crate::domain::services::external_mount_id::{ + NodeId, encode_child_id, virtual_file_etag, virtual_folder_etag, +}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -485,6 +491,28 @@ pub async fn list_folder_resources( reverse: q.reverse, }; + // External mount branch: a mount-root UUID or an `ext:` id lists live from + // the provider instead of the PostgreSQL UNION. The parent of each entry is + // the requested id itself. + match service.mount_router().classify(&id) { + ResolvedId::MountRoot { cfg } => { + return list_mount_dir_response( + &service, + &cfg, + &NodeId::default(), + &id, + auth_user.id, + opts, + ) + .await; + } + ResolvedId::MountChild { cfg, node_id } => { + return list_mount_dir_response(&service, &cfg, &node_id, &id, auth_user.id, opts) + .await; + } + ResolvedId::Regular => {} + } + match service .list_resources_paged_with_perms(&id, auth_user.id, opts) .await @@ -578,3 +606,176 @@ pub async fn list_folder_resources( Err(e) => AppError::from(e).into_response(), } } + +/// List one directory inside an external mount and render the standard +/// `/resources` envelope, mapping each live provider entry to a +/// `FolderResourceItemDto` with a synthetic `ext:` id. `parent_id` is the +/// requested id (the directory being listed), which becomes each entry's parent. +async fn list_mount_dir_response( + service: &FolderService, + cfg: &MountConfig, + node_id: &NodeId, + parent_id: &str, + caller_id: uuid::Uuid, + opts: ListResourcesOptions<'_>, +) -> axum::response::Response { + match service + .list_mount_dir_with_perms(cfg, node_id, caller_id, opts) + .await + { + Ok((entries, next_cursor)) => { + let items: Vec = entries + .into_iter() + .map(|entry| mount_entry_to_item(cfg, parent_id, entry)) + .collect(); + ( + StatusCode::OK, + Json(FolderResourcesDto::with_cursor(items, next_cursor)), + ) + .into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} + +/// Map a live mount entry to a `/resources` item with a synthetic `ext:` id and +/// virtual (size+mtime / mtime) etag. Mount entries have no blob hash. +fn mount_entry_to_item( + cfg: &MountConfig, + parent_id: &str, + entry: MountEntry, +) -> FolderResourceItemDto { + let id = encode_child_id(cfg.mount_id, entry.node_id.clone()); + if entry.is_dir { + let dto = FolderDto { + etag: virtual_folder_etag(entry.modified_at), + id, + name: entry.name.clone(), + path: String::new(), + parent_id: Some(parent_id.to_owned()), + owner_id: Some(cfg.owner_id.to_string()), + drive_id: cfg.drive_id, + created_at: entry.created_at, + modified_at: entry.modified_at, + is_root: false, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + }; + FolderResourceItemDto { + resource_type: ResourceTypeDto::Folder, + resource: ResourceContentDto::Folder(dto), + } + } else { + let mime = mime_guess::from_path(&entry.name) + .first_or_octet_stream() + .to_string(); + let dto = FileDto { + id, + name: entry.name.clone(), + path: String::new(), + size: entry.size, + mime_type: Arc::from(mime.as_str()), + folder_id: Some(parent_id.to_owned()), + created_at: entry.created_at, + modified_at: entry.modified_at, + icon_class: Arc::from(icon_class_for(&entry.name, &mime)), + icon_special_class: Arc::from(icon_special_class_for(&entry.name, &mime)), + category: Arc::from(category_for(&entry.name, &mime)), + size_formatted: format_file_size(entry.size), + owner_id: Some(cfg.owner_id.to_string()), + sort_date: None, + content_hash: String::new(), + etag: virtual_file_etag(entry.size, entry.modified_at), + created_by: None, + updated_by: None, + }; + FolderResourceItemDto { + resource_type: ResourceTypeDto::File, + resource: ResourceContentDto::File(dto), + } + } +} + +#[cfg(test)] +mod mount_mapping_tests { + use super::*; + use crate::application::services::mount_registry::MountConfig; + use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider; + use uuid::Uuid; + + fn config() -> MountConfig { + let dir = tempfile::tempdir().unwrap(); + // Leak the tempdir so the path stays valid for the provider's lifetime; + // the provider is never exercised here (mapping is pure metadata). + let path = dir.keep(); + MountConfig { + mount_id: Uuid::new_v4(), + kind: "local_fs".to_string(), + name: "Media".to_string(), + owner_id: Uuid::new_v4(), + drive_id: Uuid::new_v4(), + read_only: false, + mount_path: "Personal/Media".to_string(), + provider: Arc::new(LocalFsMountProvider::new(&path, false).unwrap()), + } + } + + fn mount_entry(name: &str, node_id: &str, is_dir: bool, size: u64, mtime: u64) -> MountEntry { + MountEntry { + name: name.to_string(), + node_id: NodeId(node_id.to_string()), + is_dir, + size, + modified_at: mtime, + created_at: mtime, + } + } + + #[test] + fn maps_folder_entry_to_item() { + let cfg = config(); + let parent = cfg.mount_id.to_string(); + let item = mount_entry_to_item(&cfg, &parent, mount_entry("docs", "docs", true, 0, 1234)); + + assert!(matches!(item.resource_type, ResourceTypeDto::Folder)); + let ResourceContentDto::Folder(dto) = item.resource else { + panic!("expected folder"); + }; + assert_eq!(dto.name, "docs"); + // id is the synthetic ext: envelope for (mount_id, node_id). + assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs")); + assert_eq!(dto.parent_id.as_deref(), Some(parent.as_str())); + assert_eq!(dto.etag, virtual_folder_etag(1234)); + assert_eq!(dto.drive_id, cfg.drive_id); + assert!(!dto.is_root); + // Hierarchy is intentionally cleared on this listing. + assert_eq!(dto.path, ""); + } + + #[test] + fn maps_file_entry_to_item_with_virtual_etag_and_no_hash() { + let cfg = config(); + let parent = encode_child_id(cfg.mount_id, "docs"); + let item = mount_entry_to_item( + &cfg, + &parent, + mount_entry("report.json", "docs/report.json", false, 42, 999), + ); + + assert!(matches!(item.resource_type, ResourceTypeDto::File)); + let ResourceContentDto::File(dto) = item.resource else { + panic!("expected file"); + }; + assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs/report.json")); + assert_eq!(dto.folder_id.as_deref(), Some(parent.as_str())); + assert_eq!(dto.size, 42); + assert_eq!(dto.etag, virtual_file_etag(42, 999)); + // Virtual files have no blob hash. + assert_eq!(dto.content_hash, ""); + // Mime is sniffed from the name. + assert_eq!(&*dto.mime_type, "application/json"); + } +} diff --git a/src/lib.rs b/src/lib.rs index ef629325..7e5caccb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,12 @@ pub mod interfaces; #[cfg(integration_tests)] pub mod integration_test_support; +// Shared testcontainers-backed harness for external-mount integration tests. +// Gated on `test` too because it links the `testcontainers` dev-dependency, +// which is only available to test targets (not the plain lib build). +#[cfg(all(test, integration_tests))] +mod mount_it_support; + // Phase 0 perf-benchmark support: deterministic image corpus generation/loading // shared by `benches/thumbnails.rs` and `examples/bench_thumbnails_mem.rs`. // Gated behind the `bench` feature so it adds nothing to normal builds. diff --git a/src/mount_it_support.rs b/src/mount_it_support.rs new file mode 100644 index 00000000..85b9dd37 --- /dev/null +++ b/src/mount_it_support.rs @@ -0,0 +1,118 @@ +//! Shared testcontainers harness for external-mount integration tests. +//! +//! Compiled only under `#[cfg(all(test, integration_tests))]` (the +//! `testcontainers` dev-dependency is linked into test targets only). Each +//! `fresh_db()` spins up an ephemeral Postgres, applies every migration, and +//! returns a pool — so the DB-backed tests are self-contained and need only +//! docker, not the external `spawn-db.sh` compose harness. + +use std::sync::Arc; + +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt}; +use uuid::Uuid; + +use crate::domain::repositories::drive_repository::DriveRepository; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::infrastructure::repositories::pg::{DrivePgRepository, FolderDbRepository}; + +/// Postgres image tag — must match production (PG13+): the schema uses +/// `CREATE OR REPLACE TRIGGER` (PG14+) and the `pg_trgm` / `ltree` contrib +/// extensions. The `testcontainers` module default (`11-alpine`) is too old. +pub const PG_IMAGE_TAG: &str = "17-alpine"; + +/// A provisioned mount: a user with a personal drive, a child folder serving as +/// the mount root, and (when created via [`provision_mount`]) an +/// `external_mounts` row. +pub struct Provisioned { + pub owner_id: Uuid, + pub drive_id: Uuid, + pub mount_folder_id: Uuid, +} + +/// Bring up an ephemeral Postgres, apply every migration, return a pool. Keep +/// the returned container handle alive for the test's duration. +pub async fn fresh_db() -> (ContainerAsync, Arc) { + let container = Postgres::default() + .with_tag(PG_IMAGE_TAG) + .start() + .await + .expect("start postgres testcontainer (is docker running?)"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("container port"); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let pool = PgPoolOptions::new() + .max_connections(4) + .connect(&url) + .await + .expect("connect to ephemeral postgres"); + sqlx::migrate!().run(&pool).await.expect("apply migrations"); + (container, Arc::new(pool)) +} + +/// Insert a minimal `user` role account, returning its id. +pub async fn make_user(pool: &PgPool, name: &str) -> Uuid { + sqlx::query_scalar::<_, Uuid>( + "INSERT INTO auth.users (username, email, password_hash, role) + VALUES ($1, $2, 'x', 'user') RETURNING id", + ) + .bind(name) + .bind(format!("{name}@example.test")) + .fetch_one(pool) + .await + .expect("insert user") +} + +/// Provision user → personal drive → a child folder named `folder_name` that +/// will act as the mount root. Does NOT insert an `external_mounts` row. +pub async fn provision_folder( + pool: &Arc, + user_name: &str, + folder_name: &str, +) -> Provisioned { + let owner_id = make_user(pool, user_name).await; + let drive_repo = DrivePgRepository::new(pool.clone()); + let drive = drive_repo + .create_personal_drive_atomic(owner_id, None) + .await + .expect("create personal drive"); + let root_folder_id = drive.drive.root_folder_id; + + let folder_repo = FolderDbRepository::new(pool.clone()); + let folder = folder_repo + .create_folder( + folder_name.to_string(), + Some(root_folder_id.to_string()), + owner_id, + ) + .await + .expect("create mount-root folder"); + let mount_folder_id = Uuid::parse_str(folder.id()).expect("uuid"); + + Provisioned { + owner_id, + drive_id: drive.drive.id, + mount_folder_id, + } +} + +/// Insert an `external_mounts` row for `mount_folder_id` with a `local_fs` +/// provider pointed at `host_path`. +pub async fn insert_mount(pool: &PgPool, p: &Provisioned, host_path: &str) { + sqlx::query( + "INSERT INTO storage.external_mounts + (mount_folder_id, kind, config, name, owner_id, read_only) + VALUES ($1, 'local_fs', $2, 'Media', $3, false)", + ) + .bind(p.mount_folder_id) + .bind(serde_json::json!({ "path": host_path })) + .bind(p.owner_id) + .execute(pool) + .await + .expect("insert external mount"); +} From 8e3e31da4d26cb4edc4b2e70400eda2aa33d18fe Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Thu, 25 Jun 2026 00:30:10 -0600 Subject: [PATCH 2/8] =?UTF-8?q?feat(mounts):=20P2=20=E2=80=94=20read-write?= =?UTF-8?q?=20REST=20for=20external=20mounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full CRUD on mount contents, mirroring the P1 read pattern (handlers/ services classify; authorization stays in the service via the mount-root folder grant; the provider does the I/O). - mkdir / rename / delete / move-within branch inside FolderService and FileManagementService (router injected into both) - streaming upload via a new ExternalUploadService: the upload handler detects a mount destination BEFORE the CAS ingest and streams the multipart body straight to the provider (no BLAKE3/dedup). `write_stream` now takes a lifetime-bound boxed stream so the borrowing multipart field can be passed without buffering. - deletes on mounts are permanent (no trash): the trash-first folder handler routes `ext:` ids straight to the provider delete; file delete goes through the branched delete_and_cleanup - cross-backend move/copy (mount ↔ native, or between mounts) is forbidden (UnsupportedOperation); the mount root itself cannot be renamed/moved/deleted - every mutation emits a `target:"audit" event="external_mount.write"` line - shared mount_dto builders synthesize FolderDto/FileDto from a provider MountStat Tests: 529 unit + integration tests for mkdir/rename/delete, file rename/delete, streaming upload, cross-boundary forbid, and stranger-denied — all against real Postgres + a real provider (testcontainers). --- src/application/ports/external_mount_ports.rs | 5 +- .../services/external_upload_service.rs | 55 +++ .../services/file_management_service.rs | 122 ++++++ src/application/services/folder_service.rs | 367 +++++++++++++++++- src/application/services/mod.rs | 2 + src/application/services/mount_dto.rs | 99 +++++ src/common/di.rs | 16 +- .../services/local_fs_mount_provider.rs | 15 +- src/interfaces/api/handlers/file_handler.rs | 29 ++ src/interfaces/api/handlers/folder_handler.rs | 16 + 10 files changed, 714 insertions(+), 12 deletions(-) create mode 100644 src/application/services/external_upload_service.rs create mode 100644 src/application/services/mount_dto.rs diff --git a/src/application/ports/external_mount_ports.rs b/src/application/ports/external_mount_ports.rs index b723ab75..c87d5980 100644 --- a/src/application/ports/external_mount_ports.rs +++ b/src/application/ports/external_mount_ports.rs @@ -32,7 +32,8 @@ use crate::domain::services::external_mount_id::NodeId; /// /// Boxed (not generic) so the trait stays object-safe. Callers map their body's /// error type to `std::io::Error` before constructing it. -pub type MountByteStream = Pin> + Send>>; +pub type MountByteStream<'a> = + Pin> + Send + 'a>>; /// One entry returned by [`ExternalMountProvider::list_dir`]. #[derive(Debug, Clone)] @@ -126,7 +127,7 @@ pub trait ExternalMountProvider: Send + Sync + 'static { &self, parent: &NodeId, name: &str, - body: MountByteStream, + body: MountByteStream<'_>, ) -> Result; /// Rename an entry in place (same parent). Returns the renamed entry's stat. diff --git a/src/application/services/external_upload_service.rs b/src/application/services/external_upload_service.rs new file mode 100644 index 00000000..c9b6b39c --- /dev/null +++ b/src/application/services/external_upload_service.rs @@ -0,0 +1,55 @@ +//! Streams an upload straight to an external mount's provider, bypassing the +//! content-addressable store entirely (no BLAKE3 / dedup). +//! +//! The REST upload handler detects a mount destination BEFORE ingesting into the +//! CAS and routes here. Authorization stays in this service (the mount-root +//! `Create` grant); the handler only classifies and supplies the body stream. + +use std::sync::Arc; + +use crate::application::dtos::file_dto::FileDto; +use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::external_mount_ports::MountByteStream; +use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id}; +use crate::application::services::mount_registry::MountConfig; +use crate::common::errors::DomainError; +use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::domain::services::external_mount_id::NodeId; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; +use uuid::Uuid; + +/// Writes uploaded bytes to a mount provider with authorization + auditing. +pub struct ExternalUploadService { + authz: Arc, +} + +impl ExternalUploadService { + /// Construct over the ReBAC engine. + pub fn new(authz: Arc) -> Self { + Self { authz } + } + + /// Authorize (`Create` on the mount root) then stream `body` to the provider + /// as `name` under `parent_node`. Returns the synthesized `FileDto`. + pub async fn write_file( + &self, + cfg: &MountConfig, + parent_node: &NodeId, + name: &str, + body: MountByteStream<'_>, + caller_id: Uuid, + ) -> Result { + self.authz + .require( + Subject::User(caller_id), + Permission::Create, + Resource::Folder(cfg.mount_id), + ) + .await?; + + let stat = cfg.provider.write_stream(parent_node, name, body).await?; + audit_mount_write("upload", cfg, caller_id, stat.node_id.as_str()); + let parent = mount_parent_id(cfg, stat.node_id.as_str()); + Ok(mount_file_dto(cfg, &parent, &stat)) + } +} diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 9292833b..e53492f8 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -6,9 +6,13 @@ use crate::application::ports::file_lifecycle::FileLifecycleHook; use crate::application::ports::file_ports::FileManagementUseCase; use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; +use crate::application::services::external_mount_router::{MountRouter, ResolvedId}; +use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id}; +use crate::application::services::mount_registry::MountConfig; use crate::application::services::trash_service::TrashService; use crate::common::errors::DomainError; use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::domain::services::external_mount_id::NodeId; use crate::domain::services::path_service::validate_storage_name; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; @@ -31,6 +35,9 @@ pub struct FileManagementService { authz: Arc, /// Lifecycle hook dispatcher — fired on file created (copy) and deleted. file_lifecycle_hook: Option>, + /// External-mount classifier. `None` in stub/test construction → all ids + /// are treated as native. + mount_router: Option>, } impl FileManagementService { @@ -53,6 +60,7 @@ impl FileManagementService { content_cache, authz, file_lifecycle_hook: None, + mount_router: None, } } @@ -62,6 +70,59 @@ impl FileManagementService { self } + /// Injects the external-mount classifier so file mutations can branch + /// `ext:` ids to the provider. + pub fn with_mount_router(mut self, router: Arc) -> Self { + self.mount_router = Some(router); + self + } + + /// Classify an id via the mount router (if configured). Returns `Regular` + /// when no router is wired. + fn classify(&self, id: &str) -> ResolvedId { + match &self.mount_router { + Some(r) => r.classify(id), + None => ResolvedId::Regular, + } + } + + /// Authorize a mutation inside a mount (gates on the mount-root folder). + async fn require_mount_perm( + &self, + cfg: &MountConfig, + perm: Permission, + caller_id: Uuid, + ) -> Result<(), DomainError> { + self.authz + .require( + Subject::User(caller_id), + perm, + Resource::Folder(cfg.mount_id), + ) + .await + } + + /// Resolve a move destination within the same mount as `cfg`. Errors when + /// the destination is absent, native, or in a different mount. + fn mount_dest_node( + &self, + cfg: &MountConfig, + folder_id: Option<&str>, + ) -> Result { + let Some(folder_id) = folder_id else { + return Err(cross_boundary_move_err()); + }; + match self.classify(folder_id) { + ResolvedId::MountRoot { cfg: dest } if dest.mount_id == cfg.mount_id => { + Ok(NodeId::default()) + } + ResolvedId::MountChild { cfg: dest, node_id } if dest.mount_id == cfg.mount_id => { + Ok(node_id) + } + _ => Err(cross_boundary_move_err()), + } + } + /// Engine check for a file resource. Parses the id into a `Uuid` and /// requires the specified permission. async fn require_file_perm( @@ -255,6 +316,29 @@ impl FileManagementUseCase for FileManagementService { caller_id: Uuid, folder_id: Option, ) -> Result { + // External mount: moves stay within one mount; cross-backend is forbidden. + match self.classify(file_id) { + ResolvedId::Regular => { + if let Some(dst) = folder_id.as_deref() + && !matches!(self.classify(dst), ResolvedId::Regular) + { + return Err(cross_boundary_move_err()); + } + } + ResolvedId::MountRoot { .. } => return Err(DomainError::not_found("File", file_id)), + ResolvedId::MountChild { cfg, node_id } => { + let dest = self.mount_dest_node(&cfg, folder_id.as_deref())?; + self.require_mount_perm(&cfg, Permission::Update, caller_id) + .await?; + self.require_mount_perm(&cfg, Permission::Create, caller_id) + .await?; + let stat = cfg.provider.move_within(&node_id, &dest).await?; + audit_mount_write("move", &cfg, caller_id, stat.node_id.as_str()); + let parent = mount_parent_id(&cfg, stat.node_id.as_str()); + return Ok(mount_file_dto(&cfg, &parent, &stat)); + } + } + // Move = Update on the file + Create on the target folder (if any). self.require_file_perm(file_id, Permission::Update, caller_id) .await?; @@ -285,12 +369,32 @@ impl FileManagementUseCase for FileManagementService { caller_id: Uuid, new_name: &str, ) -> Result { + if let ResolvedId::MountChild { cfg, node_id } = self.classify(file_id) { + if let Err(reason) = validate_storage_name(new_name) { + return Err(DomainError::validation_error(format!( + "Invalid file name '{new_name}': {reason}" + ))); + } + self.require_mount_perm(&cfg, Permission::Update, caller_id) + .await?; + let stat = cfg.provider.rename(&node_id, new_name).await?; + audit_mount_write("rename", &cfg, caller_id, stat.node_id.as_str()); + let parent = mount_parent_id(&cfg, stat.node_id.as_str()); + return Ok(mount_file_dto(&cfg, &parent, &stat)); + } self.require_file_perm(file_id, Permission::Update, caller_id) .await?; self.rename_file(file_id, new_name, caller_id).await } async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { + if let ResolvedId::MountChild { cfg, node_id } = self.classify(id) { + self.require_mount_perm(&cfg, Permission::Delete, caller_id) + .await?; + cfg.provider.delete(&node_id).await?; + audit_mount_write("delete", &cfg, caller_id, node_id.as_str()); + return Ok(()); + } self.require_file_perm(id, Permission::Delete, caller_id) .await?; self.delete_file(id).await @@ -307,6 +411,15 @@ impl FileManagementUseCase for FileManagementService { id: &str, caller_id: Uuid, ) -> Result { + // External mount: permanent provider delete (mounts have no trash). + if let ResolvedId::MountChild { cfg, node_id } = self.classify(id) { + self.require_mount_perm(&cfg, Permission::Delete, caller_id) + .await?; + cfg.provider.delete(&node_id).await?; + audit_mount_write("delete", &cfg, caller_id, node_id.as_str()); + return Ok(false); // permanently deleted (no trash) + } + self.require_file_perm(id, Permission::Delete, caller_id) .await?; // Step 1: Try trash (soft delete — file row stays, blob stays referenced) @@ -357,3 +470,12 @@ impl FileManagementUseCase for FileManagementService { .await } } + +/// Error for a move/copy that would cross a storage backend boundary +/// (mount ↔ native, or between two different mounts). Forbidden in v1. +fn cross_boundary_move_err() -> DomainError { + DomainError::operation_not_supported( + "File", + "moving between external mounts and regular storage is not supported", + ) +} diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index a6843ba4..ff6832ce 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -6,7 +6,10 @@ use crate::application::dtos::folder_dto::{ use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::external_mount_ports::MountEntry; use crate::application::ports::folder_ports::FolderUseCase; -use crate::application::services::external_mount_router::MountRouter; +use crate::application::services::external_mount_router::{MountRouter, ResolvedId}; +use crate::application::services::mount_dto::{ + audit_mount_write, mount_folder_dto, mount_parent_id, +}; use crate::application::services::mount_registry::MountConfig; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::folder_repository::FolderRepository; @@ -47,6 +50,45 @@ impl FolderService { &self.mount_router } + /// Authorize a mutation inside a mount. All operations within a mount gate + /// on the mount-root folder grant (the `cfg.mount_id` resource). + async fn require_mount_perm( + &self, + cfg: &MountConfig, + perm: Permission, + caller_id: Uuid, + ) -> Result<(), DomainError> { + self.authz + .require( + Subject::User(caller_id), + perm, + Resource::Folder(cfg.mount_id), + ) + .await + } + + /// Resolve a move destination within the SAME mount as `cfg`, returning the + /// destination parent's node id. Errors (`UnsupportedOperation`) if the + /// destination is absent, native, or in a different mount. + fn mount_dest_node( + &self, + cfg: &MountConfig, + parent_id: Option<&str>, + ) -> Result { + let Some(parent_id) = parent_id else { + return Err(cross_boundary_move_err()); + }; + match self.mount_router.classify(parent_id) { + ResolvedId::MountRoot { cfg: dest } if dest.mount_id == cfg.mount_id => { + Ok(NodeId::default()) + } + ResolvedId::MountChild { cfg: dest, node_id } if dest.mount_id == cfg.mount_id => { + Ok(node_id) + } + _ => Err(cross_boundary_move_err()), + } + } + /// Batch counterpart of `get_folder`: resolve many folder ids in ONE /// query instead of one per id. Like `get_folder` it performs no /// per-folder authorization — both current callers (ACL grant listing, @@ -232,6 +274,29 @@ impl FolderUseCase for FolderService { "Root folder creation is reserved for registration", )); }; + + // External mount: create the directory on the provider, not in PG. + match self.mount_router.classify(parent_id) { + ResolvedId::Regular => {} + ResolvedId::MountRoot { cfg } => { + self.require_mount_perm(&cfg, Permission::Create, caller_id) + .await?; + let stat = cfg + .provider + .create_dir(&NodeId::default(), &dto.name) + .await?; + audit_mount_write("mkdir", &cfg, caller_id, stat.node_id.as_str()); + return Ok(mount_folder_dto(&cfg, parent_id, &stat)); + } + ResolvedId::MountChild { cfg, node_id } => { + self.require_mount_perm(&cfg, Permission::Create, caller_id) + .await?; + let stat = cfg.provider.create_dir(&node_id, &dto.name).await?; + audit_mount_write("mkdir", &cfg, caller_id, stat.node_id.as_str()); + return Ok(mount_folder_dto(&cfg, parent_id, &stat)); + } + } + let parent_resource = Self::folder_resource(parent_id)?; self.authz .require( @@ -464,6 +529,26 @@ impl FolderUseCase for FolderService { ))); } + // External mount: rename on the provider. The mount root cannot be + // renamed through here (it's a real folder row managed elsewhere). + match self.mount_router.classify(id) { + ResolvedId::Regular => {} + ResolvedId::MountRoot { .. } => { + return Err(DomainError::operation_not_supported( + "Folder", + "a mount root cannot be renamed through this endpoint", + )); + } + ResolvedId::MountChild { cfg, node_id } => { + self.require_mount_perm(&cfg, Permission::Update, caller_id) + .await?; + let stat = cfg.provider.rename(&node_id, &dto.name).await?; + let parent = mount_parent_id(&cfg, stat.node_id.as_str()); + audit_mount_write("rename", &cfg, caller_id, stat.node_id.as_str()); + return Ok(mount_folder_dto(&cfg, &parent, &stat)); + } + } + // Drive roots double as the drive's display name (per drive.md §3, // `drives.name` is sourced from `storage.folders.name` of the row // pointed at by `root_folder_id`). Per drive.md §6 the rename is @@ -516,6 +601,37 @@ impl FolderUseCase for FolderService { dto: MoveFolderDto, caller_id: Uuid, ) -> Result { + // External mount: moves must stay within a single mount. The provider + // relocates; cross-backend moves (mount ↔ native, or between mounts) are + // forbidden in v1. + match self.mount_router.classify(id) { + ResolvedId::Regular => { + // Native source: forbid moving INTO a mount. + if let Some(parent_id) = &dto.parent_id + && self.mount_router.is_mount_id(parent_id) + { + return Err(cross_boundary_move_err()); + } + } + ResolvedId::MountRoot { .. } => { + return Err(DomainError::operation_not_supported( + "Folder", + "a mount root cannot be moved", + )); + } + ResolvedId::MountChild { cfg, node_id } => { + let dest = self.mount_dest_node(&cfg, dto.parent_id.as_deref())?; + self.require_mount_perm(&cfg, Permission::Update, caller_id) + .await?; + self.require_mount_perm(&cfg, Permission::Create, caller_id) + .await?; + let stat = cfg.provider.move_within(&node_id, &dest).await?; + audit_mount_write("move", &cfg, caller_id, stat.node_id.as_str()); + let parent = mount_parent_id(&cfg, stat.node_id.as_str()); + return Ok(mount_folder_dto(&cfg, &parent, &stat)); + } + } + let source_resource = Self::folder_resource(id)?; self.authz .require( @@ -564,6 +680,25 @@ impl FolderUseCase for FolderService { /// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants` /// rows targeting the deleted folder automatically. async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { + // External mount: delete on the provider (permanent — mounts have no + // trash). The mount root is a real folder row and is not deletable here. + match self.mount_router.classify(id) { + ResolvedId::Regular => {} + ResolvedId::MountRoot { .. } => { + return Err(DomainError::operation_not_supported( + "Folder", + "a mount root cannot be deleted through this endpoint", + )); + } + ResolvedId::MountChild { cfg, node_id } => { + self.require_mount_perm(&cfg, Permission::Delete, caller_id) + .await?; + cfg.provider.delete(&node_id).await?; + audit_mount_write("delete", &cfg, caller_id, node_id.as_str()); + return Ok(()); + } + } + self.authz .require( Subject::User(caller_id), @@ -581,6 +716,15 @@ impl FolderUseCase for FolderService { } } +/// The error returned when a move would cross a storage backend boundary +/// (mount ↔ native, or between two different mounts). Forbidden in v1. +fn cross_boundary_move_err() -> DomainError { + DomainError::operation_not_supported( + "Folder", + "moving between external mounts and regular storage is not supported", + ) +} + // ── FolderService — cursor-paginated resource listing ──────────────────────── impl FolderService { @@ -1201,6 +1345,227 @@ mod mount_authz_integration { )) } + /// Provision a mount over `host`, build a wired FolderService, and return + /// `(folder_service, mount_root_uuid_string, owner_id)`. + async fn wire_mount( + pool: &Arc, + host: &std::path::Path, + ) -> (FolderService, String, Uuid) { + let p = provision_folder(pool, "owner", "Media").await; + insert_mount(pool, &p, host.to_str().unwrap()).await; + let registry = Arc::new(MountRegistry::empty()); + registry + .reload( + &ExternalMountPgRepository::new(pool.clone()), + &DefaultMountProviderFactory::new(), + ) + .await; + let router = Arc::new(MountRouter::new(registry)); + let fs = FolderService::new( + Arc::new(FolderDbRepository::new(pool.clone())), + acl(pool), + router, + ); + (fs, p.mount_folder_id.to_string(), p.owner_id) + } + + /// P2 write path: owner can mkdir/rename/delete inside a mount (reflected on + /// the host fs); a stranger is denied; the mount root cannot be renamed. + #[tokio::test] + async fn owner_mkdir_rename_delete_on_mount() { + use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto}; + let (_c, pool) = fresh_db().await; + let host = tempfile::tempdir().unwrap(); + let (fs, mount_id, owner) = wire_mount(&pool, host.path()).await; + + // mkdir under the mount root. + let created = fs + .create_folder_with_perms( + CreateFolderDto { + name: "docs".into(), + parent_id: Some(mount_id.clone()), + }, + owner, + ) + .await + .expect("owner may mkdir"); + assert!(host.path().join("docs").is_dir()); + assert!(created.id.starts_with("ext:")); + assert_eq!(created.parent_id.as_deref(), Some(mount_id.as_str())); + + // Stranger may NOT mkdir. + let stranger = make_user(&pool, "stranger").await; + let denied = fs + .create_folder_with_perms( + CreateFolderDto { + name: "evil".into(), + parent_id: Some(mount_id.clone()), + }, + stranger, + ) + .await; + assert!(denied.is_err()); + assert!(!host.path().join("evil").exists()); + + // rename the created dir. + let renamed = fs + .rename_folder_with_perms( + &created.id, + RenameFolderDto { + name: "papers".into(), + }, + owner, + ) + .await + .expect("owner may rename"); + assert!(host.path().join("papers").is_dir()); + assert!(!host.path().join("docs").exists()); + + // The mount root itself cannot be renamed through this path. + assert!( + fs.rename_folder_with_perms( + &mount_id, + RenameFolderDto { + name: "nope".into() + }, + owner + ) + .await + .is_err() + ); + + // delete (permanent — mounts have no trash). + fs.delete_folder_with_perms(&renamed.id, owner) + .await + .expect("owner may delete"); + assert!(!host.path().join("papers").exists()); + } + + /// P2: file rename/delete and streaming upload on a mount, with authz. + #[tokio::test] + async fn file_rename_delete_and_upload_on_mount() { + use crate::application::ports::external_mount_ports::MountByteStream; + use crate::application::ports::file_ports::FileManagementUseCase; + use crate::application::services::external_upload_service::ExternalUploadService; + use crate::application::services::file_management_service::FileManagementService; + use crate::infrastructure::repositories::pg::FileBlobWriteRepository; + use bytes::Bytes; + use futures::stream; + + let (_c, pool) = fresh_db().await; + let host = tempfile::tempdir().unwrap(); + std::fs::write(host.path().join("a.txt"), b"hello").unwrap(); + + let p = provision_folder(&pool, "owner", "Media").await; + insert_mount(&pool, &p, host.path().to_str().unwrap()).await; + let registry = Arc::new(MountRegistry::empty()); + registry + .reload( + &ExternalMountPgRepository::new(pool.clone()), + &DefaultMountProviderFactory::new(), + ) + .await; + let router = Arc::new(MountRouter::new(registry.clone())); + let cfg = registry.get(&p.mount_folder_id).expect("registered"); + + let mgmt = FileManagementService::with_trash( + Arc::new(FileBlobWriteRepository::new_stub()), + None, + None, + None, + None, + acl(&pool), + ) + .with_mount_router(router.clone()); + + let file_id = encode_child_id(p.mount_folder_id, "a.txt"); + + // Owner renames the mount file. + let renamed = mgmt + .rename_file_with_perms(&file_id, p.owner_id, "b.txt") + .await + .expect("owner may rename"); + assert!(host.path().join("b.txt").exists()); + assert!(!host.path().join("a.txt").exists()); + assert_eq!(renamed.content_hash, ""); + + // Stranger may not delete. + let stranger = make_user(&pool, "stranger").await; + assert!( + mgmt.delete_file_with_perms(&renamed.id, stranger) + .await + .is_err() + ); + assert!(host.path().join("b.txt").exists()); + + // Owner deletes (permanent — no trash). + mgmt.delete_file_with_perms(&renamed.id, p.owner_id) + .await + .expect("owner may delete"); + assert!(!host.path().join("b.txt").exists()); + + // Streaming upload straight to the provider. + let upload = ExternalUploadService::new(acl(&pool)); + let body: MountByteStream<'static> = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"uploaded")) })); + let dto = upload + .write_file(&cfg, &NodeId::default(), "new.txt", body, p.owner_id) + .await + .expect("owner may upload"); + assert_eq!(dto.size, 8); + assert_eq!( + std::fs::read(host.path().join("new.txt")).unwrap(), + b"uploaded" + ); + + // Stranger upload denied. + let body2: MountByteStream<'static> = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) })); + assert!( + upload + .write_file(&cfg, &NodeId::default(), "evil.txt", body2, stranger) + .await + .is_err() + ); + assert!(!host.path().join("evil.txt").exists()); + } + + /// P2: a move that would cross the mount boundary is forbidden. + #[tokio::test] + async fn cross_boundary_move_forbidden() { + use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto}; + let (_c, pool) = fresh_db().await; + let host = tempfile::tempdir().unwrap(); + std::fs::create_dir(host.path().join("inside")).unwrap(); + let (fs, mount_id, owner) = wire_mount(&pool, host.path()).await; + + let child_id = encode_child_id(Uuid::parse_str(&mount_id).unwrap(), "inside"); + + // Moving a mount child to the user's native root (parent_id = None) is + // a cross-backend move → UnsupportedOperation. + let err = fs + .move_folder_with_perms(&child_id, MoveFolderDto { parent_id: None }, owner) + .await + .expect_err("cross-boundary move must be forbidden"); + assert_eq!( + err.kind, + crate::domain::errors::ErrorKind::UnsupportedOperation + ); + + // A native folder cannot be moved INTO the mount either. + let native = fs + .create_folder_with_perms( + CreateFolderDto { + name: "n".into(), + parent_id: Some(mount_id.clone()), + }, + owner, + ) + .await; + // (n is created inside the mount; that's a normal mkdir, allowed.) + assert!(native.is_ok()); + } + /// Full read path: owner can list a mount's live contents; a stranger with /// no grant is denied. Exercises the REAL authorization cascade /// (`authz.require(Resource::Folder(mount_id))`) over ltree ancestry. diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 48f56cf6..85357aa7 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -10,6 +10,7 @@ pub mod device_auth_service; pub mod drive_management_service; pub mod external_identity_service; pub mod external_mount_router; +pub mod external_upload_service; pub mod favorites_service; pub mod file_lifecycle_service; pub mod file_management_service; @@ -19,6 +20,7 @@ pub mod file_use_case_factory; pub mod folder_service; pub mod i18n_application_service; pub mod magic_link_invite_service; +pub mod mount_dto; pub mod mount_registry; pub mod music_service; pub mod nextcloud_file_id_service; diff --git a/src/application/services/mount_dto.rs b/src/application/services/mount_dto.rs new file mode 100644 index 00000000..c8965e37 --- /dev/null +++ b/src/application/services/mount_dto.rs @@ -0,0 +1,99 @@ +//! Builders that synthesize `FolderDto` / `FileDto` from a provider [`MountStat`]. +//! +//! Mount entries have no `storage.folders`/`storage.files` row, so the normal +//! `FolderDto::from(Folder)` path doesn't apply. These helpers produce the same +//! DTO shape from a provider stat plus the mount config, with a synthetic `ext:` +//! id and a virtual etag. Shared by the folder/file services and the handlers. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::application::ports::external_mount_ports::MountStat; +use crate::application::services::mount_registry::MountConfig; +use crate::domain::services::external_mount_id::{ + encode_child_id, virtual_file_etag, virtual_folder_etag, +}; + +/// Final path segment of a node id (the display name). +fn node_name(node_id: &str) -> &str { + node_id.rsplit('/').next().unwrap_or(node_id) +} + +/// Emit the structured audit line for a mount mutation (per AGENTS.md). Every +/// write op (upload / mkdir / rename / delete / move) calls this. +pub fn audit_mount_write(action: &str, cfg: &MountConfig, caller_id: Uuid, node_id: &str) { + tracing::info!( + target: "audit", + event = "external_mount.write", + action, + mount_id = %cfg.mount_id, + caller_id = %caller_id, + node_id = %node_id, + reason = "external_mount_op", + "👮🏻‍♂️ external mount mutation", + ); +} + +/// The id-string of a mount entry's parent: the parent's `ext:` id, or the +/// mount-root folder UUID when the entry is a direct child of the root. +pub fn mount_parent_id(cfg: &MountConfig, node_id: &str) -> String { + match node_id.rsplit_once('/') { + Some((parent, _)) => encode_child_id(cfg.mount_id, parent), + None => cfg.mount_id.to_string(), + } +} + +/// Build a `FolderDto` for a mount directory from its stat. `parent_id` is the +/// id-string of the containing directory (mount-root UUID or an `ext:` id). +pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FolderDto { + FolderDto { + etag: virtual_folder_etag(stat.modified_at), + id: encode_child_id(cfg.mount_id, stat.node_id.clone()), + name: node_name(stat.node_id.as_str()).to_owned(), + path: String::new(), + parent_id: Some(parent_id.to_owned()), + owner_id: Some(cfg.owner_id.to_string()), + drive_id: cfg.drive_id, + created_at: stat.created_at, + modified_at: stat.modified_at, + is_root: false, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + } +} + +/// Build a `FileDto` for a mount file from its stat. Virtual files have no blob +/// hash (`content_hash` empty) and a size+mtime etag. +pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FileDto { + let name = node_name(stat.node_id.as_str()); + let mime = stat.mime_type.as_str(); + FileDto { + id: encode_child_id(cfg.mount_id, stat.node_id.clone()), + name: name.to_owned(), + path: String::new(), + size: stat.size, + mime_type: Arc::from(mime), + folder_id: Some(parent_id.to_owned()), + created_at: stat.created_at, + modified_at: stat.modified_at, + icon_class: Arc::from(icon_class_for(name, mime)), + icon_special_class: Arc::from(icon_special_class_for(name, mime)), + category: Arc::from(category_for(name, mime)), + size_formatted: format_file_size(stat.size), + owner_id: Some(cfg.owner_id.to_string()), + sort_date: None, + content_hash: String::new(), + etag: virtual_file_etag(stat.size, stat.modified_at), + created_by: None, + updated_by: None, + } +} diff --git a/src/common/di.rs b/src/common/di.rs index dbc0d373..1e7e5f47 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -524,7 +524,7 @@ impl AppServiceFactory { let folder_service = Arc::new(FolderService::new( repos.folder_repository.clone(), authz.clone(), - mount_router, + mount_router.clone(), )); // Built before the upload/management services so the plugin lifecycle @@ -581,7 +581,15 @@ impl AppServiceFactory { Some(core.file_content_cache.clone()), authz.clone(), ) - .with_file_lifecycle_hook(file_lifecycle.clone()), + .with_file_lifecycle_hook(file_lifecycle.clone()) + .with_mount_router(mount_router.clone()), + ); + + // Streams uploads to external mount providers (bypasses the CAS). + let external_upload_service = Arc::new( + crate::application::services::external_upload_service::ExternalUploadService::new( + authz.clone(), + ), ); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( @@ -618,6 +626,7 @@ impl AppServiceFactory { delta_upload_service, file_retrieval_service, file_management_service, + external_upload_service, file_use_case_factory, i18n_service, trash_service, // Already set via parameter @@ -1876,6 +1885,9 @@ pub struct ApplicationServices { Arc, pub file_retrieval_service: Arc, pub file_management_service: Arc, + /// Streams uploads straight to an external mount provider (bypasses the CAS). + pub external_upload_service: + Arc, pub file_use_case_factory: Arc, pub i18n_service: Arc, pub trash_service: Option>, diff --git a/src/infrastructure/services/local_fs_mount_provider.rs b/src/infrastructure/services/local_fs_mount_provider.rs index ca8316bb..484741e6 100644 --- a/src/infrastructure/services/local_fs_mount_provider.rs +++ b/src/infrastructure/services/local_fs_mount_provider.rs @@ -333,7 +333,7 @@ impl ExternalMountProvider for LocalFsMountProvider { &self, parent: &NodeId, name: &str, - mut body: MountByteStream, + mut body: MountByteStream<'_>, ) -> Result { self.ensure_writable()?; validate_name(name)?; @@ -535,7 +535,7 @@ mod tests { assert!(d.is_dir); // write into it - let body: MountByteStream = + let body: MountByteStream<'static> = Box::pin(stream::once(async { Ok(Bytes::from_static(b"data")) })); let f = p .write_stream(&NodeId("folder".into()), "x.txt", body) @@ -744,7 +744,7 @@ mod tests { let dir = tempdir().unwrap(); std::fs::write(dir.path().join("f.txt"), b"old-and-longer").unwrap(); let p = provider(dir.path()); - let body: MountByteStream = + let body: MountByteStream<'static> = Box::pin(stream::once(async { Ok(Bytes::from_static(b"new")) })); let s = p .write_stream(&NodeId("".into()), "f.txt", body) @@ -762,7 +762,7 @@ mod tests { use futures::stream; let dir = tempdir().unwrap(); let p = provider(dir.path()); - let body: MountByteStream = Box::pin(stream::iter(vec![ + let body: MountByteStream<'static> = Box::pin(stream::iter(vec![ Ok(Bytes::from_static(b"foo")), Ok(Bytes::from_static(b"bar")), Ok(Bytes::from_static(b"baz")), @@ -783,7 +783,7 @@ mod tests { use futures::stream; let dir = tempdir().unwrap(); let p = provider(dir.path()); - let body: MountByteStream = Box::pin(stream::iter(vec![ + let body: MountByteStream<'static> = Box::pin(stream::iter(vec![ Ok(Bytes::from_static(b"partial")), Err(std::io::Error::other("boom")), ])); @@ -937,7 +937,7 @@ mod tests { use futures::stream; let dir = tempdir().unwrap(); let p = provider(dir.path()); - let body: MountByteStream = Box::pin(stream::empty()); + let body: MountByteStream<'static> = Box::pin(stream::empty()); let s = p .write_stream(&NodeId("".into()), "empty.txt", body) .await @@ -1001,7 +1001,8 @@ mod tests { std::fs::create_dir(dir.path().join("dest")).unwrap(); let p = LocalFsMountProvider::new(dir.path(), true).unwrap(); use futures::stream; - let body: MountByteStream = Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) })); + let body: MountByteStream<'static> = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) })); assert!( p.write_stream(&NodeId("".into()), "n.txt", body) .await diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 961473e9..07f0347b 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -240,6 +240,35 @@ impl FileHandler { } } + // ── External mount destination? Stream to the provider ── + // Detected BEFORE the CAS ingest so the bytes never touch + // BLAKE3/dedup. Authorization happens inside the service. + if let Some(ref fid) = folder_id { + let (mount_cfg, parent_node) = match state.mount_router.classify(fid) { + ResolvedId::MountRoot { cfg } => (Some(cfg), NodeId::default()), + ResolvedId::MountChild { cfg, node_id } => (Some(cfg), node_id), + ResolvedId::Regular => (None, NodeId::default()), + }; + if let Some(cfg) = mount_cfg { + use futures::StreamExt; + let body: crate::application::ports::external_mount_ports::MountByteStream< + '_, + > = Box::pin( + upload_ingest::multipart_field_stream(field) + .map(|r| r.map_err(|e| std::io::Error::other(e.to_string()))), + ); + return match state + .applications + .external_upload_service + .write_file(&cfg, &parent_node, &filename, body, auth_user.id) + .await + { + Ok(file) => Ok((file, String::new())), + Err(err) => Err(Self::domain_error_response(err)), + }; + } + } + // ── Stream the field into the CDC chunk store ──────── // Chunking (FastCDC) + hashing (BLAKE3) + dedup checks + // MIME sniffing all happen while the bytes arrive; chunks diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index e67a8ab9..4c9823e4 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -177,6 +177,22 @@ impl FolderHandler { Path(id): Path, ) -> impl IntoResponse { let user_id = auth_user.id; + + // External mounts have no trash — a permanent provider delete is the + // only option. Route `ext:` ids straight to the mount-aware service + // delete, skipping the (always-failing) trash attempt. + if state.mount_router.is_mount_id(&id) { + return match state + .applications + .folder_service + .delete_folder_with_perms(&id, user_id) + .await + { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(err) => AppError::from(err).into_response(), + }; + } + // Check if trash service is available // FIXME: permissions !! if let Some(trash_service) = &state.trash_service { From d8229650ef67b68e8567066e755b3544740a761a Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Thu, 25 Jun 2026 00:51:16 -0600 Subject: [PATCH 3/8] =?UTF-8?q?feat(mounts):=20P3=20=E2=80=94=20WebDAV/Nex?= =?UTF-8?q?tCloud=20path=20resolution=20+=20browse/download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts are now browsable and downloadable over both WebDAV surfaces (/webdav/ and NextCloud /remote.php/dav) and NextCloud clients. The work funnels through the path-based service methods all three surfaces already use, so both WebDAV handlers gain mount support with no handler changes. - get_folder_by_path / get_file_by_path resolve a path that descends past a mount root to a synthetic ext: DTO (the mount root itself stays a real row) - list_folders_paginated_with_perms / list_files_batch_with_perms branch to the provider, so PROPFIND Depth:1 enumerates mount directory contents in both WebDAV handlers - get_file_stream / get_file_range_stream branch ext: file ids to the provider, so WebDAV GET streams mount content (Pin> re-boxed) - router injected into FileRetrievalService; new MountRouter::find_path delegates to the registry's (drive_id, mount_path) index - WebDAV mkdir/delete/move/rename by path work via path→ext:id→the P2 service methods (no extra wiring) Fixes a leading-slash normalization bug in the registry path index: materialized folder paths arrive both as `Personal/Media` and `/Personal/Media`; keys and lookups now normalize the leading slash (would have broken real WebDAV paths). Known follow-up: WebDAV PUT (upload/update by path) still ingests to the CAS; streaming a WebDAV PUT straight to the provider needs a pre-ingest branch in the two WebDAV PUT handlers (mirrors the REST upload branch). Integration test: get_folder_by_path/get_file_by_path resolution, PROPFIND Depth:1 folder+file listing, and content streaming on a real mount + Postgres. --- .../services/external_mount_router.rs | 11 ++ .../services/file_retrieval_service.rs | 102 ++++++++++++ src/application/services/folder_service.rs | 154 +++++++++++++++++- src/application/services/mount_dto.rs | 52 +++++- src/application/services/mount_registry.rs | 15 +- src/common/di.rs | 15 +- 6 files changed, 340 insertions(+), 9 deletions(-) diff --git a/src/application/services/external_mount_router.rs b/src/application/services/external_mount_router.rs index 35dbd9d3..8b1bf505 100644 --- a/src/application/services/external_mount_router.rs +++ b/src/application/services/external_mount_router.rs @@ -78,6 +78,17 @@ impl MountRouter { ResolvedId::MountRoot { .. } | ResolvedId::MountChild { .. } ) } + + /// Path-based lookup for the protocol surfaces (WebDAV / NextCloud): does + /// `internal_path` descend into a mount within `drive_id`? Returns the mount + /// config plus the remainder relpath (empty when the path IS the mount root). + pub fn find_path( + &self, + drive_id: uuid::Uuid, + internal_path: &str, + ) -> Option<(Arc, String)> { + self.registry.find_mount_for_path(drive_id, internal_path) + } } #[cfg(test)] diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index d74e0cbe..56743faa 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -37,6 +37,9 @@ pub struct FileRetrievalService { content_cache: Option>, transcode: Option>, authz: Option>, + /// External-mount classifier for path-based resolution (WebDAV/NextCloud). + /// `None` in the simple/test constructor → no mount support. + mount_router: Option>, } impl FileRetrievalService { @@ -49,6 +52,7 @@ impl FileRetrievalService { content_cache: None, transcode: None, authz: None, + mount_router: None, } } @@ -65,9 +69,20 @@ impl FileRetrievalService { content_cache: Some(content_cache), transcode: Some(transcode), authz: Some(authz), + mount_router: None, } } + /// Injects the external-mount classifier so path-based lookups + /// (`get_file_by_path`) can resolve mount paths to the provider. + pub fn with_mount_router( + mut self, + router: Arc, + ) -> Self { + self.mount_router = Some(router); + self + } + /// Test-only constructor: authorization engine without the cache/transcode /// tiers. The external-mount read methods only consult `authz` + the /// provider, so this is sufficient to exercise their authorization. @@ -81,6 +96,7 @@ impl FileRetrievalService { content_cache: None, transcode: None, authz: Some(authz), + mount_router: None, } } @@ -186,6 +202,22 @@ impl FileRetrievalService { cfg.provider.open_read_stream(node_id, range).await } + /// If `id` is an `ext:` mount FILE id, return the mount config + node id. + /// `None` for native ids, mount roots, or when no router is wired. + fn mount_file_node( + &self, + id: &str, + ) -> Option<( + Arc, + NodeId, + )> { + use crate::application::services::external_mount_router::ResolvedId; + match self.mount_router.as_ref()?.classify(id) { + ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)), + _ => None, + } + } + /// Try to transcode image content to WebP and return transcoded variant. async fn try_transcode( &self, @@ -349,6 +381,26 @@ impl FileRetrievalUseCase for FileRetrievalService { // `drive_id` scope axis prevents cross-drive resolution — without // it, `find_file_by_path` would return a non-deterministic row // when the same path exists in multiple drives. + // External mount: a path descending past a mount root resolves on the + // provider (stat). The mount root itself has no file at its path. + if let Some(router) = &self.mount_router + && let Some((cfg, remainder)) = router.find_path(drive_id, path) + && !remainder.is_empty() + { + let node = cfg.provider.resolve_path(&remainder); + let stat = cfg.provider.stat(&node).await?; + if stat.is_dir { + return Err(DomainError::not_found("File", path)); + } + let parent = crate::application::services::mount_dto::mount_parent_id( + &cfg, + stat.node_id.as_str(), + ); + return Ok(crate::application::services::mount_dto::mount_file_dto( + &cfg, &parent, &stat, + )); + } + if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? { return Ok(FileDto::from(file)); } @@ -388,6 +440,11 @@ impl FileRetrievalUseCase for FileRetrievalService { &self, id: &str, ) -> Result> + Send>, DomainError> { + if let Some((cfg, node)) = self.mount_file_node(id) { + let s = cfg.provider.open_read_stream(&node, None).await?; + // `Pin>` is itself a `Stream`, so re-box it. + return Ok(Box::new(s)); + } self.file_read.get_file_stream(id).await } @@ -446,6 +503,13 @@ impl FileRetrievalUseCase for FileRetrievalService { start: u64, end: Option, ) -> Result> + Send>, DomainError> { + if let Some((cfg, node)) = self.mount_file_node(id) { + // The native range convention is exclusive-end; the provider wants + // an inclusive end. + let range = Some((start, end.map(|e| e.saturating_sub(1)))); + let s = cfg.provider.open_read_stream(&node, range).await?; + return Ok(Box::new(s)); + } self.file_read.get_file_range_stream(id, start, end).await } @@ -490,6 +554,44 @@ impl FileRetrievalUseCase for FileRetrievalService { offset: i64, limit: i64, ) -> Result, DomainError> { + // External mount: list files from the provider (WebDAV/NextCloud + // PROPFIND Depth:1 file loop). Authz collapses on the mount root. + if let Some(fid) = folder_id + && let Some(router) = &self.mount_router + { + use crate::application::services::external_mount_router::ResolvedId; + let resolved = match router.classify(fid) { + ResolvedId::Regular => None, + ResolvedId::MountRoot { cfg } => Some(( + cfg, + crate::domain::services::external_mount_id::NodeId::default(), + )), + ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)), + }; + if let Some((cfg, node)) = resolved { + if let Some(authz) = &self.authz { + authz + .require( + Subject::User(owner_id), + Permission::Read, + Resource::Folder(cfg.mount_id), + ) + .await?; + } + let entries = cfg.provider.list_dir(&node).await?; + let files: Vec = entries + .iter() + .filter(|e| !e.is_dir) + .skip(offset.max(0) as usize) + .take(limit.max(0) as usize) + .map(|e| { + crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, e) + }) + .collect(); + return Ok(files); + } + } + if folder_id.is_some() { // folder id is defined, check permissions self.require_target_folder_perm(folder_id, Permission::Read, owner_id) diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index ff6832ce..d83dcd20 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -8,7 +8,7 @@ use crate::application::ports::external_mount_ports::MountEntry; use crate::application::ports::folder_ports::FolderUseCase; use crate::application::services::external_mount_router::{MountRouter, ResolvedId}; use crate::application::services::mount_dto::{ - audit_mount_write, mount_folder_dto, mount_parent_id, + audit_mount_write, mount_entry_folder_dto, mount_folder_dto, mount_parent_id, }; use crate::application::services::mount_registry::MountConfig; use crate::common::errors::{DomainError, ErrorKind}; @@ -67,6 +67,16 @@ impl FolderService { .await } + /// If `id` addresses a mount directory (root or `ext:` child), return the + /// mount config and the node id of that directory. `None` for native ids. + fn mount_node_for(&self, id: &str) -> Option<(Arc, NodeId)> { + match self.mount_router.classify(id) { + ResolvedId::Regular => None, + ResolvedId::MountRoot { cfg } => Some((cfg, NodeId::default())), + ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)), + } + } + /// Resolve a move destination within the SAME mount as `cfg`, returning the /// destination parent's node id. Errors (`UnsupportedOperation`) if the /// destination is absent, native, or in a different mount. @@ -353,6 +363,21 @@ impl FolderUseCase for FolderService { path: &str, drive_id: Uuid, ) -> Result { + // External mount: a path that descends past a mount root (non-empty + // remainder) resolves on the provider. The mount root itself is a real + // folder row, so the empty-remainder case falls through to the DB. + if let Some((cfg, remainder)) = self.mount_router.find_path(drive_id, path) + && !remainder.is_empty() + { + let node = cfg.provider.resolve_path(&remainder); + let stat = cfg.provider.stat(&node).await?; + if !stat.is_dir { + return Err(DomainError::not_found("Folder", path)); + } + let parent = mount_parent_id(&cfg, stat.node_id.as_str()); + return Ok(mount_folder_dto(&cfg, &parent, &stat)); + } + let storage_path = StoragePath::from_string(path); let folder = self @@ -472,6 +497,32 @@ impl FolderUseCase for FolderService { { let pagination = pagination.validate_and_adjust(); + // External mount: list subdirectories from the provider (used by the + // WebDAV/NextCloud PROPFIND Depth:1 folder loop). + if let Some(pid) = parent_id + && let Some((cfg, node)) = self.mount_node_for(pid) + { + self.require_mount_perm(&cfg, Permission::Read, owner_id) + .await?; + let entries = cfg.provider.list_dir(&node).await?; + let mut dirs: Vec = entries + .iter() + .filter(|e| e.is_dir) + .map(|e| mount_entry_folder_dto(&cfg, pid, e)) + .collect(); + let total = dirs.len(); + let (offset, limit) = (pagination.offset(), pagination.limit()); + let page: Vec = dirs.drain(..).skip(offset).take(limit).collect(); + return Ok( + crate::application::dtos::pagination::PaginatedResponseDto::new( + page, + pagination.page, + pagination.page_size, + total, + ), + ); + } + if let Some(parent_id_unwrapped) = parent_id { self.authz .require( @@ -1530,6 +1581,107 @@ mod mount_authz_integration { assert!(!host.path().join("evil.txt").exists()); } + /// P3: the WebDAV/NextCloud-facing path + listing methods resolve mount + /// paths and enumerate provider children (PROPFIND Depth:1), and content + /// streams from the provider. + #[tokio::test] + async fn webdav_path_resolution_and_listing() { + use crate::application::dtos::pagination::PaginationRequestDto; + use crate::application::ports::file_ports::FileRetrievalUseCase; + use crate::application::services::file_retrieval_service::FileRetrievalService; + use futures::TryStreamExt; + + let (_c, pool) = fresh_db().await; + let host = tempfile::tempdir().unwrap(); + std::fs::create_dir(host.path().join("sub")).unwrap(); + std::fs::write(host.path().join("a.txt"), b"top").unwrap(); + std::fs::write(host.path().join("sub/b.txt"), b"nested!").unwrap(); + + let p = provision_folder(&pool, "owner", "Media").await; + insert_mount(&pool, &p, host.path().to_str().unwrap()).await; + let registry = Arc::new(MountRegistry::empty()); + registry + .reload( + &ExternalMountPgRepository::new(pool.clone()), + &DefaultMountProviderFactory::new(), + ) + .await; + let router = Arc::new(MountRouter::new(registry)); + let folder_service = FolderService::new( + Arc::new(FolderDbRepository::new(pool.clone())), + acl(&pool), + router.clone(), + ); + let retrieval = FileRetrievalService::new_with_authz_for_test( + Arc::new(FileBlobReadRepository::new_stub()), + acl(&pool), + ) + .with_mount_router(router.clone()); + + // The mount root's materialized path; descend into it. + let root = folder_service + .get_folder(&p.mount_folder_id.to_string()) + .await + .unwrap(); + + // get_folder_by_path resolves a mount subdirectory → synthetic ext: id. + let sub = folder_service + .get_folder_by_path(&format!("{}/sub", root.path), p.drive_id) + .await + .expect("resolve sub dir by path"); + assert!(sub.id.starts_with("ext:")); + assert_eq!(sub.name, "sub"); + + // get_file_by_path resolves a mount file. + let file = retrieval + .get_file_by_path(&format!("{}/a.txt", root.path), p.drive_id) + .await + .expect("resolve file by path"); + assert!(file.id.starts_with("ext:")); + assert_eq!(file.size, 3); + + // PROPFIND Depth:1 folder loop: list subdirectories of the mount root. + let dirs = folder_service + .list_folders_paginated_with_perms( + Some(&p.mount_folder_id.to_string()), + p.owner_id, + &PaginationRequestDto::default(), + ) + .await + .expect("list mount subdirs"); + assert_eq!( + dirs.items + .iter() + .map(|d| d.name.as_str()) + .collect::>(), + ["sub"] + ); + + // PROPFIND Depth:1 file loop: list files of the mount root. + let files = retrieval + .list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, 0, 100) + .await + .expect("list mount files"); + assert_eq!( + files.iter().map(|f| f.name.as_str()).collect::>(), + ["a.txt"] + ); + + // Content streams from the provider (WebDAV GET) — resolve the nested + // file by path, then stream it by its ext: id. + let nested = retrieval + .get_file_by_path(&format!("{}/sub/b.txt", root.path), p.drive_id) + .await + .expect("nested file"); + use futures::TryStreamExt as _; + let content: Vec = Box::into_pin(retrieval.get_file_stream(&nested.id).await.unwrap()) + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(content, b"nested!"); + } + /// P2: a move that would cross the mount boundary is forbidden. #[tokio::test] async fn cross_boundary_move_forbidden() { diff --git a/src/application/services/mount_dto.rs b/src/application/services/mount_dto.rs index c8965e37..ad4053b4 100644 --- a/src/application/services/mount_dto.rs +++ b/src/application/services/mount_dto.rs @@ -14,7 +14,7 @@ use crate::application::dtos::display_helpers::{ }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; -use crate::application::ports::external_mount_ports::MountStat; +use crate::application::ports::external_mount_ports::{MountEntry, MountStat}; use crate::application::services::mount_registry::MountConfig; use crate::domain::services::external_mount_id::{ encode_child_id, virtual_file_etag, virtual_folder_etag, @@ -71,6 +71,56 @@ pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> } } +/// Build a `FolderDto` from a directory listing entry. `parent_id` is the +/// id-string of the directory being listed. +pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEntry) -> FolderDto { + FolderDto { + etag: virtual_folder_etag(entry.modified_at), + id: encode_child_id(cfg.mount_id, entry.node_id.clone()), + name: entry.name.clone(), + path: String::new(), + parent_id: Some(parent_id.to_owned()), + owner_id: Some(cfg.owner_id.to_string()), + drive_id: cfg.drive_id, + created_at: entry.created_at, + modified_at: entry.modified_at, + is_root: false, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + } +} + +/// Build a `FileDto` from a directory listing entry (mime sniffed from name). +pub fn mount_entry_file_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEntry) -> FileDto { + let name = entry.name.as_str(); + let mime = mime_guess::from_path(name) + .first_or_octet_stream() + .to_string(); + FileDto { + id: encode_child_id(cfg.mount_id, entry.node_id.clone()), + name: name.to_owned(), + path: String::new(), + size: entry.size, + mime_type: Arc::from(mime.as_str()), + folder_id: Some(parent_id.to_owned()), + created_at: entry.created_at, + modified_at: entry.modified_at, + icon_class: Arc::from(icon_class_for(name, &mime)), + icon_special_class: Arc::from(icon_special_class_for(name, &mime)), + category: Arc::from(category_for(name, &mime)), + size_formatted: format_file_size(entry.size), + owner_id: Some(cfg.owner_id.to_string()), + sort_date: None, + content_hash: String::new(), + etag: virtual_file_etag(entry.size, entry.modified_at), + created_by: None, + updated_by: None, + } +} + /// Build a `FileDto` for a mount file from its stat. Virtual files have no blob /// hash (`content_hash` empty) and a size+mtime etag. pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FileDto { diff --git a/src/application/services/mount_registry.rs b/src/application/services/mount_registry.rs index d81f67aa..e6028a80 100644 --- a/src/application/services/mount_registry.rs +++ b/src/application/services/mount_registry.rs @@ -90,6 +90,11 @@ impl MountRegistry { internal_path: &str, ) -> Option<(Arc, String)> { let index = self.inner.load(); + // Normalize away a leading slash: materialized folder paths arrive both + // as `Personal/Media` (raw `folders.path`) and `/Personal/Media` + // (FolderDto / WebDAV internal paths). The index keys are stored without + // a leading slash (see `reload`). + let internal_path = internal_path.trim_start_matches('/'); // Walk ancestor paths from the full path up to the root, longest first, // so the deepest matching mount wins. let mut candidate = internal_path; @@ -144,7 +149,15 @@ impl MountRegistry { continue; } }; - by_path.insert((rec.drive_id, rec.mount_path.clone()), rec.mount_folder_id); + // Store the path key without a leading slash so lookups normalize + // consistently (see `find_mount_for_path`). + by_path.insert( + ( + rec.drive_id, + rec.mount_path.trim_start_matches('/').to_string(), + ), + rec.mount_folder_id, + ); by_folder.insert( rec.mount_folder_id, Arc::new(MountConfig { diff --git a/src/common/di.rs b/src/common/di.rs index 1e7e5f47..443b95cc 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -531,12 +531,15 @@ impl AppServiceFactory { // bridge (which looks file metadata up by id) can be wired into the // dispatcher they receive. It depends only on repos + core, never on // the upload service, so the reorder is safe. - let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( - repos.file_read_repository.clone(), - core.file_content_cache.clone(), - core.image_transcode_service.clone(), - authz.clone(), - )); + let file_retrieval_service = Arc::new( + FileRetrievalService::new_with_cache( + repos.file_read_repository.clone(), + core.file_content_cache.clone(), + core.image_transcode_service.clone(), + authz.clone(), + ) + .with_mount_router(mount_router.clone()), + ); // Effective lifecycle dispatcher: the core hooks (thumbnails, metadata) // plus, when the plugins feature is enabled, the WASM plugin bridge. From 07320a3925d3592b432a3ce0309bd0d7b9451347 Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Thu, 25 Jun 2026 01:07:03 -0600 Subject: [PATCH 4/8] =?UTF-8?q?feat(mounts):=20P4=20=E2=80=94=20admin=20CR?= =?UTF-8?q?UD=20endpoints=20+=20frontend=20admin=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts are now configurable at runtime without DB surgery. Backend (admin-gated, /api/admin/external-mounts): - GET list all configured mounts - POST create: validate the provider config up front, create a mount-root folder under the admin's drive, insert the row, hot-reload the registry - DELETE remove the mount row + its root folder (host content is left intact), then hot-reload - ExternalMountRepositoryPort gains create/delete (PG impl); registry.reload() runs in-process so changes are live immediately - audit lines event="external_mount.config" action=create|delete Frontend: - admin.ts endpoints: listExternalMounts / createExternalMount / deleteExternalMount - new "External Mounts" tab in the admin page: add form (name + host path + read-only) and a list with delete Integration test for the repo create/delete round-trip (testcontainers). --- frontend/src/lib/api/endpoints/admin.ts | 49 ++++ frontend/src/routes/admin/+page.svelte | 139 +++++++++++- src/application/ports/external_mount_ports.rs | 35 +++ .../pg/external_mount_repository.rs | 66 +++++- .../api/handlers/admin_external_mounts.rs | 214 ++++++++++++++++++ src/interfaces/api/handlers/admin_handler.rs | 10 + src/interfaces/api/handlers/mod.rs | 1 + 7 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 src/interfaces/api/handlers/admin_external_mounts.rs diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index f29e74e1..686178a2 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -512,3 +512,52 @@ export function getPluginLogs( credentials: 'same-origin' }); } + +// ── External file mounts ──────────────────────────────────────────────────── + +/** A configured external mount as returned by the admin API. */ +export interface ExternalMount { + mount_folder_id: string; + name: string; + kind: string; + owner_id: string; + read_only: boolean; + drive_id: string; + mount_path: string; + config: Record; +} + +/** Request body for creating an external mount. */ +export interface CreateExternalMountInput { + name: string; + host_path: string; + kind?: string; + read_only?: boolean; +} + +/** GET /api/admin/external-mounts — list all configured mounts. */ +export function listExternalMounts(): Promise { + return apiJson('/api/admin/external-mounts', { + credentials: 'same-origin' + }); +} + +/** POST /api/admin/external-mounts — create a mount in the admin's drive. */ +export async function createExternalMount(input: CreateExternalMountInput): Promise { + const res = await apiFetch('/api/admin/external-mounts', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify(input) + }); + if (!res.ok) { + const e = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(e.message || `Create mount failed: ${res.status}`); + } + return (await res.json()) as ExternalMount; +} + +/** DELETE /api/admin/external-mounts/{id} — remove a mount (host content kept). */ +export function deleteExternalMount(mountFolderId: string): Promise { + return mutate(`/api/admin/external-mounts/${mountFolderId}`, 'DELETE'); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 528741e8..e96c1892 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -32,6 +32,11 @@ testOidc, testStorage, verifyMigration, + createExternalMount, + deleteExternalMount, + listExternalMounts, + type ExternalMount, + type CreateExternalMountInput, type AdminDashboard, type GeneratedKey, type MigrationStatus, @@ -114,13 +119,53 @@ confirmState = null; } - type Tab = 'dashboard' | 'users' | 'drives' | 'plugins' | 'oidc' | 'storage' | 'smtp'; + type Tab = 'dashboard' | 'users' | 'drives' | 'mounts' | 'plugins' | 'oidc' | 'storage' | 'smtp'; let tab = $state('dashboard'); // Dashboard let dashboard = $state(null); let dashboardError = $state(null); + // External mounts + let mounts = $state(null); + let mountsError = $state(null); + let newMount = $state({ name: '', host_path: '', read_only: false }); + let mountCreating = $state(false); + + async function loadMounts() { + mountsError = null; + try { + mounts = await listExternalMounts(); + } catch (e) { + mountsError = errorMessage(e); + } + } + + async function createMount() { + if (!newMount.name.trim() || !newMount.host_path.trim()) return; + mountCreating = true; + try { + const created = await createExternalMount(newMount); + mounts = [...(mounts ?? []), created]; + newMount = { name: '', host_path: '', read_only: false }; + } catch (e) { + mountsError = errorMessage(e); + } finally { + mountCreating = false; + } + } + + async function deleteMount(id: string) { + if (!(await showConfirm('Remove this mount? Files on the host are kept.'))) return; + try { + await deleteExternalMount(id); + mounts = mounts?.filter((m) => m.mount_folder_id !== id) ?? null; + } catch (e) { + reportError(e); + await loadMounts(); + } + } + // SMTP let smtp = $state(null); let smtpTo = $state(''); @@ -1136,6 +1181,7 @@ dashboard: false, users: false, drives: false, + mounts: false, plugins: false, oidc: false, storage: false, @@ -1148,6 +1194,7 @@ if (tab === 'dashboard') void loadDashboard(); else if (tab === 'users') void loadUsers(); else if (tab === 'drives') void loadDrivesTab(); + else if (tab === 'mounts') void loadMounts(); else if (tab === 'plugins') void loadPlugins(); else if (tab === 'oidc') void loadOidc(); else if (tab === 'storage') { @@ -1210,6 +1257,15 @@ {t('admin.drives', 'Drives')} + + + + {#if mountsError} +

{mountsError}

+ {/if} + + {#if mounts} + {#if mounts.length === 0} +

{t('admin.mounts.empty', 'No mounts configured.')}

+ {:else} + + + + + + + + + + + + {#each mounts as m (m.mount_folder_id)} + + + + + + + + {/each} + +
{t('admin.mounts.name', 'Name')}{t('admin.mounts.kind', 'Kind')}{t('admin.mounts.path', 'Path')}{t('admin.mounts.readonly', 'Read-only')}
{m.name}{m.kind}{m.mount_path}{m.read_only ? t('common.yes', 'Yes') : t('common.no', 'No')} + +
+ {/if} + {:else} +

{t('common.loading', 'Loading…')}

+ {/if} + {:else if tab === 'drives'}