From a82faa5eafc488745cf201045ada1f405346a78c Mon Sep 17 00:00:00 2001 From: Diocrafts Date: Sun, 8 Feb 2026 13:40:23 +0100 Subject: [PATCH] refactoring hexagonal and clean architecture --- Cargo.lock | 333 ++- Cargo.toml | 12 +- check-frontend.py | 61 - src/application/dtos/address_book_dto.rs | 16 +- src/application/dtos/contact_dto.rs | 49 +- src/application/dtos/share_dto.rs | 28 +- src/application/ports/cache_ports.rs | 124 + src/application/ports/chunked_upload_ports.rs | 110 + src/application/ports/compression_ports.rs | 40 + src/application/ports/dedup_ports.rs | 148 ++ src/application/ports/favorites_ports.rs | 24 + src/application/ports/file_ports.rs | 97 + src/application/ports/inbound.rs | 51 - src/application/ports/mod.rs | 9 +- src/application/ports/outbound.rs | 148 +- src/application/ports/recent_ports.rs | 26 + src/application/ports/storage_ports.rs | 126 +- src/application/ports/thumbnail_ports.rs | 91 + src/application/ports/transcode_ports.rs | 106 + src/application/ports/zip_ports.rs | 24 + src/application/services/batch_operations.rs | 118 +- src/application/services/contact_service.rs | 259 +-- src/application/services/favorites_service.rs | 168 +- .../services/file_management_service.rs | 150 +- .../services/file_retrieval_service.rs | 243 +- src/application/services/file_service.rs | 437 ---- .../services/file_upload_service.rs | 316 ++- src/application/services/mod.rs | 1 - src/application/services/recent_service.rs | 212 +- src/application/services/search_service.rs | 7 +- src/application/services/share_service.rs | 190 +- src/application/services/storage_mediator.rs | 134 +- .../services/storage_usage_service.rs | 9 +- src/application/services/trash_service.rs | 100 +- .../services/trash_service_test.rs | 232 +- src/common/adapters.rs | 292 --- src/common/config.rs | 22 +- src/common/di.rs | 971 +++----- src/common/mod.rs | 4 +- src/common/stubs.rs | 752 ++++++ src/domain/entities/contact.rs | 326 ++- src/domain/entities/session.rs | 55 +- src/domain/entities/share.rs | 184 +- src/domain/entities/trashed_item.rs | 74 +- src/domain/repositories/file_repository.rs | 394 +--- src/domain/repositories/folder_repository.rs | 129 +- .../adapters/contact_storage_adapter.rs | 227 +- .../auth_factory.rs | 0 src/{common => infrastructure}/db.rs | 0 src/infrastructure/mod.rs | 2 + .../repositories/composite_file_repository.rs | 139 ++ .../repositories/file_fs_read_repository.rs | 471 ++-- .../repositories/file_fs_repository.rs | 2027 ----------------- .../repositories/file_fs_repository_trash.rs | 368 --- .../repositories/file_fs_write_repository.rs | 625 +++-- .../repositories/file_metadata_manager.rs | 223 -- .../repositories/file_path_resolver.rs | 132 -- .../repositories/folder_fs_repository.rs | 378 +-- .../folder_fs_repository_trash.rs | 14 +- src/infrastructure/repositories/mod.rs | 12 +- .../repositories/parallel_file_processor.rs | 27 +- .../pg/address_book_pg_repository.rs | 146 +- .../repositories/pg/contact_pg_repository.rs | 74 +- .../pg/favorites_pg_repository.rs | 130 ++ src/infrastructure/repositories/pg/mod.rs | 4 + .../pg/recent_items_pg_repository.rs | 155 ++ .../repositories/pg/session_pg_repository.rs | 64 +- .../repositories/repository_errors.rs | 96 + .../repositories/share_fs_repository.rs | 50 +- .../repositories/trash_fs_repository.rs | 28 +- src/infrastructure/services/buffer_pool.rs | 47 +- src/infrastructure/services/cache_manager.rs | 200 -- .../services/chunked_upload_service.rs | 96 + .../services/compression_service.rs | 58 +- src/infrastructure/services/dedup_service.rs | 131 +- .../services/file_content_cache.rs | 28 + .../services/file_metadata_cache.rs | 67 +- .../services/id_mapping_service.rs | 1 + .../services/image_transcode_service.rs | 61 + src/infrastructure/services/mod.rs | 1 - .../services/thumbnail_service.rs | 62 + .../services/trash_cleanup_service.rs | 4 +- .../services/write_behind_cache.rs | 54 + src/infrastructure/services/zip_service.rs | 22 +- src/interfaces/api/handlers/auth_handler.rs | 89 +- .../api/handlers/carddav_handler.rs | 73 +- .../api/handlers/chunked_upload_handler.rs | 40 +- src/interfaces/api/handlers/dedup_handler.rs | 6 +- src/interfaces/api/handlers/file_handler.rs | 1383 ++++------- src/interfaces/api/handlers/folder_handler.rs | 41 +- src/interfaces/api/handlers/i18n_handler.rs | 10 +- src/interfaces/api/handlers/share_handler.rs | 9 +- src/interfaces/api/handlers/trash_handler.rs | 31 +- src/interfaces/api/handlers/webdav_handler.rs | 94 +- src/interfaces/api/mod.rs | 3 +- src/interfaces/api/routes.rs | 688 +----- src/interfaces/middleware/auth.rs | 165 +- src/interfaces/middleware/cache.rs | 60 +- src/interfaces/mod.rs | 1 + src/lib.rs | 3 +- src/main.rs | 832 +------ 101 files changed, 7433 insertions(+), 9721 deletions(-) delete mode 100755 check-frontend.py create mode 100644 src/application/ports/cache_ports.rs create mode 100644 src/application/ports/chunked_upload_ports.rs create mode 100644 src/application/ports/compression_ports.rs create mode 100644 src/application/ports/dedup_ports.rs create mode 100644 src/application/ports/thumbnail_ports.rs create mode 100644 src/application/ports/transcode_ports.rs create mode 100644 src/application/ports/zip_ports.rs delete mode 100644 src/application/services/file_service.rs delete mode 100644 src/common/adapters.rs create mode 100644 src/common/stubs.rs rename src/{common => infrastructure}/auth_factory.rs (100%) rename src/{common => infrastructure}/db.rs (100%) create mode 100644 src/infrastructure/repositories/composite_file_repository.rs delete mode 100644 src/infrastructure/repositories/file_fs_repository.rs delete mode 100644 src/infrastructure/repositories/file_fs_repository_trash.rs delete mode 100644 src/infrastructure/repositories/file_metadata_manager.rs delete mode 100644 src/infrastructure/repositories/file_path_resolver.rs create mode 100644 src/infrastructure/repositories/pg/favorites_pg_repository.rs create mode 100644 src/infrastructure/repositories/pg/recent_items_pg_repository.rs create mode 100644 src/infrastructure/repositories/repository_errors.rs delete mode 100644 src/infrastructure/services/cache_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 2b011979..079e9446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,9 +51,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arbitrary" @@ -207,6 +207,12 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -278,21 +284,11 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "bzip2" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] @@ -435,6 +431,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -445,6 +453,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deflate64" version = "0.1.10" @@ -523,6 +558,44 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -532,6 +605,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -594,6 +688,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -602,12 +712,13 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -633,6 +744,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -756,6 +873,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -765,10 +883,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -778,11 +894,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -795,6 +909,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -822,7 +947,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -830,6 +955,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1169,16 +1299,24 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", "pem", - "ring", + "rand", + "rsa", "serde", "serde_json", + "sha2", + "signature", "simple_asn1", ] @@ -1191,6 +1329,12 @@ dependencies = [ "spin", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.180" @@ -1253,32 +1397,21 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] -name = "lzma-rs" -version = "0.3.0" +name = "lzma-rust2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" dependencies = [ - "byteorder", "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", + "sha2", ] [[package]] @@ -1510,8 +1643,8 @@ dependencies = [ "futures", "hex", "http-body", + "http-body-util", "http-range-header", - "httpdate", "hyper", "image", "jsonwebtoken", @@ -1539,6 +1672,30 @@ dependencies = [ "zip", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1681,6 +1838,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1716,6 +1879,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1829,6 +2001,16 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1863,6 +2045,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1928,6 +2119,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -2412,9 +2623,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -2433,9 +2644,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3137,15 +3348,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yoke" version = "0.8.1" @@ -3265,34 +3467,37 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" dependencies = [ "aes", "arbitrary", "bzip2", "constant_time_eq", "crc32fast", - "crossbeam-utils", "deflate64", - "displaydoc", "flate2", "getrandom 0.3.4", "hmac", "indexmap", - "lzma-rs", + "lzma-rust2", "memchr", "pbkdf2", + "ppmd-rust", "sha1", - "thiserror", "time", - "xz2", "zeroize", "zopfli", "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index a178cdd7..4e83bb9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,8 @@ bytes = "1.11.0" tempfile = "3.24.0" tower = "0.5.3" tower-http = { version = "0.6.8", features = ["fs", "compression-gzip", "trace", "cors", "add-extension", "request-id"] } -flate2 = "1.1.8" -zip = "2.1.0" +flate2 = "1.1.9" +zip = "=6.0.0" tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } chrono = { version = "0.4.43", features = ["serde"] } @@ -29,21 +29,21 @@ async-trait = "0.1.89" thiserror = "2.0.18" mockall = { version = "0.14.0", optional = true } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls", "chrono", "uuid", "json"] } -anyhow = "1.0.100" -jsonwebtoken = "9.3.1" +anyhow = "1.0.101" +jsonwebtoken = { version = "10.1.0", features = ["rust_crypto"] } argon2 = "0.5.3" rand_core = { version = "0.6.4", features = ["std"] } hyper = { version = "1.8.1", features = ["full"] } quick-xml = "0.39.0" dotenv = "0.15.0" -lru = "0.12" +lru = "0.16.3" memmap2 = "0.9" -httpdate = "1.0" http-range-header = "0.4" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } md5 = "0.8.0" sha2 = "0.10.9" hex = "0.4.3" +http-body-util = "0.1.3" [features] default = [] diff --git a/check-frontend.py b/check-frontend.py deleted file mode 100755 index ada8ff1e..00000000 --- a/check-frontend.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -import requests -import json - -SERVER_URL = "http://localhost:8086" - -print("Checking which files are visible from the frontend...") - -def make_request(url, method="GET", params=None): - print(f"\n[{method}] {url}") - - try: - if method == "GET": - response = requests.get(url, params=params) - else: - return None - - if response.status_code == 200: - if response.headers.get('Content-Type', '').startswith('application/json'): - return response.json() - else: - return response.text[:100] + "..." - else: - return f"Error: {response.status_code} - {response.text}" - except Exception as e: - return f"Exception: {str(e)}" - -# List files at root -print("Files at root level:") -root_files = make_request(f"{SERVER_URL}/api/files") -if isinstance(root_files, list): - for file in root_files: - print(f"- {file.get('name')} (ID: {file.get('id')})") -else: - print(f"Error: {root_files}") - -# List files in folder-storage:1 -print("\nFiles in folder-storage:1:") -folder_files = make_request(f"{SERVER_URL}/api/files", params={"folder_id": "folder-storage:1"}) -if isinstance(folder_files, list): - for file in folder_files: - print(f"- {file.get('name')} (ID: {file.get('id')})") -else: - print(f"Error: {folder_files}") - -# Check file_ids.json and folder_ids.json -print("\nContents of file_ids.json:") -try: - with open("./storage/file_ids.json", "r") as f: - file_ids = json.load(f) - print(json.dumps(file_ids, indent=2)) -except Exception as e: - print(f"Error reading file_ids.json: {e}") - -print("\nContents of folder_ids.json:") -try: - with open("./storage/folder_ids.json", "r") as f: - folder_ids = json.load(f) - print(json.dumps(folder_ids, indent=2)) -except Exception as e: - print(f"Error reading folder_ids.json: {e}") \ No newline at end of file diff --git a/src/application/dtos/address_book_dto.rs b/src/application/dtos/address_book_dto.rs index 8a0da27e..67a0f233 100644 --- a/src/application/dtos/address_book_dto.rs +++ b/src/application/dtos/address_book_dto.rs @@ -32,14 +32,14 @@ impl Default for AddressBookDto { impl From for AddressBookDto { fn from(book: AddressBook) -> Self { Self { - id: book.id.to_string(), - name: book.name, - owner_id: book.owner_id, - description: book.description, - color: book.color, - is_public: book.is_public, - created_at: book.created_at, - updated_at: book.updated_at, + id: book.id().to_string(), + name: book.name().to_string(), + owner_id: book.owner_id().to_string(), + description: book.description().map(|s| s.to_string()), + color: book.color().map(|s| s.to_string()), + is_public: book.is_public(), + created_at: *book.created_at(), + updated_at: *book.updated_at(), } } } diff --git a/src/application/dtos/contact_dto.rs b/src/application/dtos/contact_dto.rs index 52128b30..aed4ec3d 100644 --- a/src/application/dtos/contact_dto.rs +++ b/src/application/dtos/contact_dto.rs @@ -112,26 +112,27 @@ impl Default for ContactDto { impl From for ContactDto { fn from(contact: Contact) -> Self { + let parts = contact.into_parts(); Self { - id: contact.id.to_string(), - address_book_id: contact.address_book_id.to_string(), - uid: contact.uid, - full_name: contact.full_name, - first_name: contact.first_name, - last_name: contact.last_name, - nickname: contact.nickname, - email: contact.email.into_iter().map(EmailDto::from).collect(), - phone: contact.phone.into_iter().map(PhoneDto::from).collect(), - address: contact.address.into_iter().map(AddressDto::from).collect(), - organization: contact.organization, - title: contact.title, - notes: contact.notes, - photo_url: contact.photo_url, - birthday: contact.birthday, - anniversary: contact.anniversary, - created_at: contact.created_at, - updated_at: contact.updated_at, - etag: contact.etag, + id: parts.id.to_string(), + address_book_id: parts.address_book_id.to_string(), + uid: parts.uid, + full_name: parts.full_name, + first_name: parts.first_name, + last_name: parts.last_name, + nickname: parts.nickname, + email: parts.email.into_iter().map(EmailDto::from).collect(), + phone: parts.phone.into_iter().map(PhoneDto::from).collect(), + address: parts.address.into_iter().map(AddressDto::from).collect(), + organization: parts.organization, + title: parts.title, + notes: parts.notes, + photo_url: parts.photo_url, + birthday: parts.birthday, + anniversary: parts.anniversary, + created_at: parts.created_at, + updated_at: parts.updated_at, + etag: parts.etag, } } } @@ -193,11 +194,11 @@ pub struct ContactGroupDto { impl From for ContactGroupDto { fn from(group: ContactGroup) -> Self { Self { - id: group.id.to_string(), - address_book_id: group.address_book_id.to_string(), - name: group.name, - created_at: group.created_at, - updated_at: group.updated_at, + id: group.id().to_string(), + address_book_id: group.address_book_id().to_string(), + name: group.name().to_string(), + created_at: *group.created_at(), + updated_at: *group.updated_at(), members_count: None, } } diff --git a/src/application/dtos/share_dto.rs b/src/application/dtos/share_dto.rs index 30d8cd6b..7a0e54af 100644 --- a/src/application/dtos/share_dto.rs +++ b/src/application/dtos/share_dto.rs @@ -43,20 +43,20 @@ pub struct UpdateShareDto { /// Extension methods to convert between DTOs and domain entities impl ShareDto { pub fn from_entity(share: &Share, base_url: &str) -> Self { - let url = format!("{}/s/{}", base_url, share.token); + let url = format!("{}/s/{}", base_url, share.token()); Self { - id: share.id.clone(), - item_id: share.item_id.clone(), - item_type: share.item_type.to_string(), - token: share.token.clone(), + id: share.id().to_string(), + item_id: share.item_id().to_string(), + item_type: share.item_type().to_string(), + token: share.token().to_string(), url, - has_password: share.password_hash.is_some(), - expires_at: share.expires_at, - permissions: SharePermissionsDto::from_entity(&share.permissions), - created_at: share.created_at, - created_by: share.created_by.clone(), - access_count: share.access_count, + has_password: share.has_password(), + expires_at: share.expires_at(), + permissions: SharePermissionsDto::from_entity(share.permissions()), + created_at: share.created_at(), + created_by: share.created_by().to_string(), + access_count: share.access_count(), } } } @@ -64,9 +64,9 @@ impl ShareDto { impl SharePermissionsDto { pub fn from_entity(permissions: &SharePermissions) -> Self { Self { - read: permissions.read, - write: permissions.write, - reshare: permissions.reshare, + read: permissions.read(), + write: permissions.write(), + reshare: permissions.reshare(), } } diff --git a/src/application/ports/cache_ports.rs b/src/application/ports/cache_ports.rs new file mode 100644 index 00000000..3f0edb28 --- /dev/null +++ b/src/application/ports/cache_ports.rs @@ -0,0 +1,124 @@ +//! Cache Ports — Application-layer abstractions for all caching concerns. +//! +//! This module defines ports (traits) for: +//! - **WriteBehindCachePort**: deferred write caching for zero-latency uploads. +//! - **MetadataCachePort**: file/directory metadata caching (existence, size, timestamps). +//! - **ContentCachePort**: hot file content caching (small files served from RAM). +//! +//! The application and interface layers remain independent of the caching +//! implementation details. + +use std::path::{Path, PathBuf}; +use async_trait::async_trait; +use bytes::Bytes; +use crate::common::errors::DomainError; + +/// Statistics for monitoring write-behind cache status. +#[derive(Debug, Clone, Default)] +pub struct WriteBehindStatsDto { + pub pending_count: usize, + pub pending_bytes: usize, + pub total_writes: u64, + pub total_bytes_written: u64, + pub cache_hits: u64, + pub avg_flush_time_us: u64, +} + +/// Port for write-behind cache operations. +/// +/// Provides deferred write semantics: small files are held in memory +/// and the response is returned immediately, while actual disk writes +/// happen asynchronously in the background. +#[async_trait] +pub trait WriteBehindCachePort: Send + Sync + 'static { + /// Check if a file size is eligible for write-behind caching. + fn is_eligible_size(&self, size: usize) -> bool; + + /// Put a file in the pending write cache. + /// + /// Returns `Ok(true)` if cached successfully, `Ok(false)` if cache is full. + async fn put_pending( + &self, + file_id: String, + content: Bytes, + target_path: PathBuf, + ) -> Result; + + /// Get content from cache if the file is still pending flush. + async fn get_pending(&self, file_id: &str) -> Option; + + /// Check if a file is pending flush. + async fn is_pending(&self, file_id: &str) -> bool; + + /// Force immediate flush of a specific file. + async fn force_flush(&self, file_id: &str) -> Result<(), DomainError>; + + /// Flush all pending writes immediately. + async fn flush_all(&self) -> Result<(), DomainError>; + + /// Gracefully shutdown the cache, flushing all pending writes. + async fn shutdown(&self) -> Result<(), DomainError>; + + /// Get current cache statistics. + async fn get_stats(&self) -> WriteBehindStatsDto; +} + +// ─── Metadata Cache ────────────────────────────────────────── + +/// Lightweight DTO for cached file/directory metadata. +#[derive(Debug, Clone)] +pub struct CachedMetadataDto { + pub path: PathBuf, + pub exists: bool, + pub is_file: bool, + pub size: Option, + pub mime_type: Option, + pub created_at: Option, + pub modified_at: Option, +} + +/// Port for file/directory metadata caching. +/// +/// Provides fast lookups for existence, size, timestamps and MIME types +/// without hitting the filesystem on every request. +#[async_trait] +pub trait MetadataCachePort: Send + Sync + 'static { + /// Get cached metadata for a path, or `None` on miss / expired. + async fn get_metadata(&self, path: &Path) -> Option; + + /// Check whether a path is a file (cached). Returns `None` on miss. + async fn is_file(&self, path: &Path) -> Option; + + /// Read actual filesystem metadata and update the cache entry. + async fn refresh_metadata(&self, path: &Path) -> Result; + + /// Invalidate a single cache entry. + async fn invalidate(&self, path: &Path); + + /// Invalidate all entries under a directory (recursive prefix match). + async fn invalidate_directory(&self, dir_path: &Path); +} + +// ─── Content Cache ─────────────────────────────────────────── + +/// Port for hot file content caching (small frequently-accessed files in RAM). +/// +/// Implementations should use LRU eviction and respect size limits so that +/// the application layer never needs to know the concrete cache type. +#[async_trait] +pub trait ContentCachePort: Send + Sync + 'static { + /// Check whether a file of the given size should be cached. + fn should_cache(&self, size: usize) -> bool; + + /// Get cached content. Returns `(content, etag, content_type)` on hit. + async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)>; + + /// Store content in the cache (may evict older entries). + async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String); + + /// Remove a file from the cache (e.g. on delete/update). + async fn invalidate(&self, file_id: &str); + + /// Clear the entire cache. + async fn clear(&self); +} diff --git a/src/application/ports/chunked_upload_ports.rs b/src/application/ports/chunked_upload_ports.rs new file mode 100644 index 00000000..4251242f --- /dev/null +++ b/src/application/ports/chunked_upload_ports.rs @@ -0,0 +1,110 @@ +//! Chunked Upload Port - Application layer abstraction for resumable chunked uploads. +//! +//! This module defines the port (trait) and DTOs for chunked/resumable upload +//! operations, keeping the application and interface layers independent of +//! the specific upload implementation (TUS-like protocol, S3 multipart, etc.). + +use std::path::PathBuf; +use async_trait::async_trait; +use bytes::Bytes; +use serde::Serialize; +use crate::common::errors::DomainError; + +/// Default chunk size (5 MB) — optimised for parallel transfers. +pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024; + +/// Minimum file size to use chunked upload (10 MB). +pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024; + +/// Response returned when a new upload session is created. +#[derive(Debug, Clone, Serialize)] +pub struct CreateUploadResponseDto { + pub upload_id: String, + pub chunk_size: usize, + pub total_chunks: usize, + pub expires_at: u64, +} + +/// Response returned after a single chunk is uploaded. +#[derive(Debug, Clone, Serialize)] +pub struct ChunkUploadResponseDto { + pub chunk_index: usize, + pub bytes_received: u64, + pub progress: f64, + pub is_complete: bool, +} + +/// Response for querying upload session status. +#[derive(Debug, Clone, Serialize)] +pub struct UploadStatusResponseDto { + pub upload_id: String, + pub filename: String, + pub total_size: u64, + pub bytes_received: u64, + pub progress: f64, + pub total_chunks: usize, + pub completed_chunks: usize, + pub pending_chunks: Vec, + pub is_complete: bool, +} + +/// Port for chunked/resumable upload operations. +/// +/// Implementations manage upload sessions, chunk storage, reassembly, +/// and cleanup, while the application layer only interacts through +/// this abstraction. +#[async_trait] +pub trait ChunkedUploadPort: Send + Sync + 'static { + /// Create a new upload session. + /// + /// Returns session metadata including the upload ID, chunk size, + /// total number of chunks, and expiration timestamp. + async fn create_session( + &self, + filename: String, + folder_id: Option, + content_type: String, + total_size: u64, + chunk_size: Option, + ) -> Result; + + /// Upload a single chunk. + /// + /// `checksum` is an optional MD5 hex string for integrity verification. + async fn upload_chunk( + &self, + upload_id: &str, + chunk_index: usize, + data: Bytes, + checksum: Option, + ) -> Result; + + /// Get the current status of an upload session. + async fn get_status( + &self, + upload_id: &str, + ) -> Result; + + /// Assemble all chunks into the final file. + /// + /// Returns `(assembled_file_path, filename, folder_id, content_type, total_size)`. + async fn complete_upload( + &self, + upload_id: &str, + ) -> Result<(PathBuf, String, Option, String, u64), DomainError>; + + /// Finalize upload: clean up the session and temporary files. + async fn finalize_upload( + &self, + upload_id: &str, + ) -> Result<(), DomainError>; + + /// Cancel an upload and clean up all temporary data. + async fn cancel_upload( + &self, + upload_id: &str, + ) -> Result<(), DomainError>; + + /// Check if a file size qualifies for chunked upload. + fn should_use_chunked(&self, size: u64) -> bool; +} diff --git a/src/application/ports/compression_ports.rs b/src/application/ports/compression_ports.rs new file mode 100644 index 00000000..3540792a --- /dev/null +++ b/src/application/ports/compression_ports.rs @@ -0,0 +1,40 @@ +//! Compression Port - Application layer abstraction for compression services. +//! +//! This module defines the port (trait) for compression operations, +//! keeping the application and interface layers independent of specific +//! compression implementations (gzip, zstd, etc.). + +use async_trait::async_trait; +use crate::common::errors::DomainError; + +/// Compression level settings for file compression operations. +/// +/// These levels control the trade-off between compression speed and ratio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompressionLevel { + /// No compression (passthrough) + None = 0, + /// Fast compression with lower ratio + Fast = 1, + /// Balanced compression (default) + Default = 6, + /// Maximum compression (slower) + Best = 9, +} + +/// Port for compression/decompression operations. +/// +/// Implementations of this trait provide the actual compression logic +/// (e.g., gzip, zstd) while the application layer remains agnostic +/// of the specific algorithm used. +#[async_trait] +pub trait CompressionPort: Send + Sync + 'static { + /// Compress data in memory. + async fn compress_data(&self, data: &[u8], level: CompressionLevel) -> Result, DomainError>; + + /// Decompress data in memory. + async fn decompress_data(&self, compressed_data: &[u8]) -> Result, DomainError>; + + /// Determine if a file should be compressed based on its MIME type and size. + fn should_compress(&self, mime_type: &str, size: u64) -> bool; +} diff --git a/src/application/ports/dedup_ports.rs b/src/application/ports/dedup_ports.rs new file mode 100644 index 00000000..5c810ba7 --- /dev/null +++ b/src/application/ports/dedup_ports.rs @@ -0,0 +1,148 @@ +//! Deduplication Port - Application layer abstraction for content-addressable storage. +//! +//! This module defines the port (trait) and DTOs for deduplication operations, +//! keeping the application and interface layers independent of the specific +//! content-addressable storage implementation. + +use std::path::{Path, PathBuf}; +use async_trait::async_trait; +use bytes::Bytes; +use serde::Serialize; +use crate::common::errors::DomainError; + +/// Metadata of a stored blob in the dedup system. +#[derive(Debug, Clone, Serialize)] +pub struct BlobMetadataDto { + /// SHA-256 hash of the content. + pub hash: String, + /// Size in bytes. + pub size: u64, + /// Number of references to this blob. + pub ref_count: u32, + /// Original content type (for serving). + pub content_type: Option, +} + +/// Result of a deduplication store operation. +#[derive(Debug, Clone)] +pub enum DedupResultDto { + /// New content was stored (first occurrence). + NewBlob { + hash: String, + size: u64, + blob_path: PathBuf, + }, + /// Content already existed; a reference was added instead. + ExistingBlob { + hash: String, + size: u64, + blob_path: PathBuf, + saved_bytes: u64, + }, +} + +impl DedupResultDto { + pub fn hash(&self) -> &str { + match self { + DedupResultDto::NewBlob { hash, .. } => hash, + DedupResultDto::ExistingBlob { hash, .. } => hash, + } + } + + pub fn size(&self) -> u64 { + match self { + DedupResultDto::NewBlob { size, .. } => *size, + DedupResultDto::ExistingBlob { size, .. } => *size, + } + } + + pub fn blob_path(&self) -> &Path { + match self { + DedupResultDto::NewBlob { blob_path, .. } => blob_path, + DedupResultDto::ExistingBlob { blob_path, .. } => blob_path, + } + } + + pub fn was_deduplicated(&self) -> bool { + matches!(self, DedupResultDto::ExistingBlob { .. }) + } +} + +/// Statistics for the deduplication service. +#[derive(Debug, Clone, Default, Serialize)] +pub struct DedupStatsDto { + /// Total number of unique blobs. + pub total_blobs: u64, + /// Total bytes stored (actual disk usage). + pub total_bytes_stored: u64, + /// Total bytes referenced (logical size). + pub total_bytes_referenced: u64, + /// Bytes saved through deduplication. + pub bytes_saved: u64, + /// Number of deduplication hits. + pub dedup_hits: u64, + /// Deduplication ratio (referenced / stored). + pub dedup_ratio: f64, +} + +/// Port for content-addressable deduplication operations. +/// +/// Implementations store files by their content hash, eliminating +/// duplicate storage automatically. Multiple file references can +/// point to the same physical blob. +#[async_trait] +pub trait DedupPort: Send + Sync + 'static { + /// Store content with deduplication (from bytes). + /// + /// If content with the same hash already exists, a reference is added + /// instead of storing a duplicate. + async fn store_bytes( + &self, + content: &[u8], + content_type: Option, + ) -> Result; + + /// Store content with deduplication (streaming from file). + async fn store_from_file( + &self, + source_path: &Path, + content_type: Option, + ) -> Result; + + /// Check if a blob with the given hash exists. + async fn blob_exists(&self, hash: &str) -> bool; + + /// Get metadata for a blob. + async fn get_blob_metadata(&self, hash: &str) -> Option; + + /// Read blob content as raw bytes. + async fn read_blob(&self, hash: &str) -> Result, DomainError>; + + /// Read blob content as `Bytes`. + async fn read_blob_bytes(&self, hash: &str) -> Result; + + /// Add a reference to a blob (increment ref_count). + async fn add_reference(&self, hash: &str) -> Result<(), DomainError>; + + /// Remove a reference from a blob. + /// + /// Returns `true` if the blob was deleted (ref_count reached 0). + async fn remove_reference(&self, hash: &str) -> Result; + + /// Calculate SHA-256 hash of in-memory content. + fn hash_bytes(&self, content: &[u8]) -> String; + + /// Calculate SHA-256 hash of a file (streaming). + async fn hash_file(&self, path: &Path) -> Result; + + /// Get deduplication statistics. + async fn get_stats(&self) -> DedupStatsDto; + + /// Flush the index to persistent storage. + async fn flush(&self) -> Result<(), DomainError>; + + /// Verify integrity of all stored blobs. + /// + /// Returns a list of issues found (empty if everything is OK). + async fn verify_integrity(&self) -> Result, DomainError>; +} diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs index 139dbfbd..2a52d2f2 100644 --- a/src/application/ports/favorites_ports.rs +++ b/src/application/ports/favorites_ports.rs @@ -16,4 +16,28 @@ pub trait FavoritesUseCase: Send + Sync { /// Check if an item is in user's favorites async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; +} + +// ───────────────────────────────────────────────────── +// Outbound port — persistence abstraction +// ───────────────────────────────────────────────────── + +/// Puerto secundario (outbound) para persistencia de favoritos. +/// +/// Los servicios de aplicación dependen de este trait en lugar de +/// acceder directamente a `PgPool`. La implementación concreta +/// vive en `infrastructure::repositories::pg`. +#[async_trait] +pub trait FavoritesRepositoryPort: Send + Sync + 'static { + /// Obtiene todos los favoritos de un usuario. + async fn get_favorites(&self, user_id: &str) -> Result>; + + /// Añade un ítem a favoritos. Devuelve `Ok(())` si ya existía (idempotente). + async fn add_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>; + + /// Elimina un ítem de favoritos. Devuelve `true` si existía. + async fn remove_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; + + /// Comprueba si un ítem está en favoritos. + async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; } \ No newline at end of file diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 438fda59..2f6f6899 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::pin::Pin; use async_trait::async_trait; use bytes::Bytes; use futures::Stream; @@ -6,6 +7,21 @@ use futures::Stream; use crate::application::dtos::file_dto::FileDto; use crate::common::errors::DomainError; +// ───────────────────────────────────────────────────── +// Upload port +// ───────────────────────────────────────────────────── + +/// Strategy chosen by the upload service based on file size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadStrategy { + /// Instant (<256KB): write-behind cache, ~0ms latency + WriteBehind, + /// Buffered (256KB–1MB): full bytes in memory then write + Buffered, + /// Streaming (≥1MB): pipe chunks directly to disk + Streaming, +} + /// Puerto primario para operaciones de subida de archivos #[async_trait] pub trait FileUploadUseCase: Send + Sync + 'static { @@ -17,6 +33,47 @@ pub trait FileUploadUseCase: Send + Sync + 'static { content_type: String, content: Vec, ) -> Result; + + /// Smart upload: picks the best strategy (write-behind / buffered / streaming) + /// and handles dedup automatically. + /// + /// Returns `(FileDto, UploadStrategy)` so the handler can log the chosen tier. + async fn smart_upload( + &self, + name: String, + folder_id: Option, + content_type: String, + chunks: Vec, + total_size: usize, + ) -> Result<(FileDto, UploadStrategy), DomainError>; + + /// Crea un nuevo archivo en la ruta especificada (para WebDAV) + async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result; + + /// Actualiza el contenido de un archivo existente (para WebDAV) + async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>; +} + +// ───────────────────────────────────────────────────── +// Retrieval / download port +// ───────────────────────────────────────────────────── + +/// Optimized file content returned by the retrieval service. +/// +/// The handler only needs to map each variant to the appropriate HTTP +/// response; all caching / transcoding / mmap decisions happen in the +/// application layer. +pub enum OptimizedFileContent { + /// Small-file content (possibly transcoded / compressed) already in RAM. + Bytes { + data: Bytes, + mime_type: String, + was_transcoded: bool, + }, + /// Memory-mapped file (10–100 MB). + Mmap(Bytes), + /// Streaming download for very large files (≥100 MB). + Stream(Pin> + Send>>), } /// Puerto primario para operaciones de recuperación de archivos @@ -25,6 +82,9 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Obtiene un archivo por su ID async fn get_file(&self, id: &str) -> Result; + /// Obtiene un archivo por su ruta (para WebDAV) + async fn get_file_by_path(&self, path: &str) -> Result; + /// Lista archivos en una carpeta async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; @@ -33,8 +93,32 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Obtiene contenido de archivo como stream (para archivos grandes) async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError>; + + /// Optimized multi-tier download. + /// + /// Internalises: write-behind lookup → content-cache → WebP transcode → + /// mmap → streaming, returning an `OptimizedFileContent` variant so the + /// handler only builds the HTTP response. + async fn get_file_optimized( + &self, + id: &str, + accept_webp: bool, + prefer_original: bool, + ) -> Result<(FileDto, OptimizedFileContent), DomainError>; + + /// Range-based streaming for HTTP Range Requests (video seek, resumable DL). + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError>; } +// ───────────────────────────────────────────────────── +// Management port (delete, move) +// ───────────────────────────────────────────────────── + /// Puerto primario para operaciones de gestión de archivos #[async_trait] pub trait FileManagementUseCase: Send + Sync + 'static { @@ -43,6 +127,19 @@ pub trait FileManagementUseCase: Send + Sync + 'static { /// Elimina un archivo async fn delete_file(&self, id: &str) -> Result<(), DomainError>; + + /// Smart delete: trash-first with dedup reference cleanup. + /// + /// 1. Tries to move to trash (soft delete). + /// 2. Falls back to permanent delete if trash unavailable/failed. + /// 3. Decrements the dedup reference count for the content hash. + /// + /// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted. + async fn delete_with_cleanup( + &self, + id: &str, + user_id: &str, + ) -> Result; } /// Factory para crear implementaciones de casos de uso de archivos diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 5e4a2149..942778f0 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -1,53 +1,9 @@ -use std::sync::Arc; use async_trait::async_trait; -use bytes::Bytes; -use futures::Stream; -use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto}; use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::common::errors::DomainError; -/// Puerto primario para operaciones de archivos -#[async_trait] -pub trait FileUseCase: Send + Sync + 'static { - /// Sube un nuevo archivo desde bytes - async fn upload_file( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> Result; - - /// Obtiene un archivo por su ID - async fn get_file(&self, id: &str) -> Result; - - /// Obtiene un archivo por su ruta (para WebDAV) - async fn get_file_by_path(&self, path: &str) -> Result; - - /// Crea un nuevo archivo en la ruta especificada (para WebDAV) - async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result; - - /// Actualiza el contenido de un archivo existente (para WebDAV) - async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>; - - /// Lista archivos en una carpeta - async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; - - /// Elimina un archivo - async fn delete_file(&self, id: &str) -> Result<(), DomainError>; - - /// Obtiene contenido de archivo como bytes (para archivos pequeños) - async fn get_file_content(&self, id: &str) -> Result, DomainError>; - - /// Obtiene contenido de archivo como stream (para archivos grandes) - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError>; - - /// Mueve un archivo a otra carpeta - async fn move_file(&self, file_id: &str, folder_id: Option) -> Result; -} - /// Puerto primario para operaciones de carpetas #[async_trait] pub trait FolderUseCase: Send + Sync + 'static { @@ -102,11 +58,4 @@ pub trait SearchUseCase: Send + Sync + 'static { * @return Resultado indicando éxito o error */ async fn clear_search_cache(&self) -> Result<(), DomainError>; -} - -/// Factory para crear implementaciones de casos de uso -pub trait UseCaseFactory { - fn create_file_use_case(&self) -> Arc; - fn create_folder_use_case(&self) -> Arc; - fn create_search_use_case(&self) -> Arc; } \ No newline at end of file diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 2cfcad07..b8179351 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -1,6 +1,10 @@ pub mod auth_ports; +pub mod cache_ports; pub mod calendar_ports; pub mod carddav_ports; +pub mod chunked_upload_ports; +pub mod compression_ports; +pub mod dedup_ports; pub mod favorites_ports; pub mod file_ports; pub mod inbound; @@ -8,4 +12,7 @@ pub mod outbound; pub mod recent_ports; pub mod share_ports; pub mod storage_ports; -pub mod trash_ports; \ No newline at end of file +pub mod thumbnail_ports; +pub mod transcode_ports; +pub mod trash_ports; +pub mod zip_ports; \ No newline at end of file diff --git a/src/application/ports/outbound.rs b/src/application/ports/outbound.rs index 6579f614..5090ebdf 100644 --- a/src/application/ports/outbound.rs +++ b/src/application/ports/outbound.rs @@ -1,13 +1,14 @@ use std::path::PathBuf; use async_trait::async_trait; -use bytes::Bytes; -use futures::Stream; -use crate::domain::entities::file::File; -use crate::domain::entities::folder::Folder; use crate::domain::services::path_service::StoragePath; use crate::common::errors::DomainError; +// Re-export domain repository traits for backward compatibility +pub use crate::domain::repositories::folder_repository::FolderRepository; + +use super::storage_ports::{FileReadPort, FileWritePort}; + /// Puerto secundario para operaciones de almacenamiento #[async_trait] pub trait StoragePort: Send + Sync + 'static { @@ -24,124 +25,29 @@ pub trait StoragePort: Send + Sync + 'static { async fn directory_exists(&self, storage_path: &StoragePath) -> Result; } -/// Puerto secundario para persistencia de archivos -#[async_trait] -pub trait FileStoragePort: Send + Sync + 'static { - /// Guarda un nuevo archivo desde bytes - async fn save_file( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> Result; - - /// Guarda un archivo desde stream (streaming upload) - /// Escribe chunks directamente al disco sin acumular en memoria - async fn save_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - stream: std::pin::Pin> + Send>>, - ) -> Result; - - /// Obtiene un archivo por su ID - async fn get_file(&self, id: &str) -> Result; - - /// Lista archivos en una carpeta - async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; - - /// Elimina un archivo - async fn delete_file(&self, id: &str) -> Result<(), DomainError>; - - /// Obtiene contenido de archivo como bytes - async fn get_file_content(&self, id: &str) -> Result, DomainError>; - - /// Obtiene contenido de archivo como stream - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError>; - - /// Obtiene un rango de contenido como stream (para HTTP Range Requests) - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option - ) -> Result> + Send>, DomainError>; - - /// Memory-maps archivo para acceso zero-copy (ideal para 10-100MB) - async fn get_file_mmap(&self, id: &str) -> Result; - - /// Mueve un archivo a otra carpeta - async fn move_file(&self, file_id: &str, target_folder_id: Option) -> Result; - - /// Obtiene la ruta de almacenamiento de un archivo - async fn get_file_path(&self, id: &str) -> Result; - - /// Obtiene el ID de la carpeta padre para una ruta dada (necesario para WebDAV) - async fn get_parent_folder_id(&self, path: &str) -> Result; - - /// Actualiza el contenido de un archivo existente - async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError>; - - /// Registra metadatos de archivo SIN escribir contenido a disco (write-behind) - /// - /// Este método: - /// 1. Genera ID único para el archivo - /// 2. Calcula la ruta de destino - /// 3. Registra el mapping ID->path - /// 4. Devuelve File entity con path real (pero archivo no existe aún) - /// - /// El contenido se escribe posteriormente via WriteBehindCache - /// Beneficio: Respuesta ~0ms para uploads pequeños - async fn register_file_deferred( - &self, - name: String, - folder_id: Option, - content_type: String, - size: u64, - ) -> Result<(File, PathBuf), DomainError>; -} +/// Puerto unificado para persistencia de archivos (backward-compatible). +/// +/// Ahora es un **supertrait** de `FileReadPort + FileWritePort`. +/// Cualquier tipo que implemente ambos ports obtiene `FileStoragePort` +/// automáticamente via blanket impl. Esto permite migrar consumidores +/// gradualmente a los ports granulares mientras los existentes siguen +/// funcionando sin cambios. +pub trait FileStoragePort: FileReadPort + FileWritePort {} -/// Puerto secundario para persistencia de carpetas -#[async_trait] -pub trait FolderStoragePort: Send + Sync + 'static { - /// Crea una nueva carpeta - async fn create_folder(&self, name: String, parent_id: Option) -> Result; - - /// Obtiene una carpeta por su ID - async fn get_folder(&self, id: &str) -> Result; - - /// Obtiene una carpeta por su ruta - async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result; - - /// Lista carpetas dentro de una carpeta padre - async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - - /// Lista carpetas con paginación - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - offset: usize, - limit: usize, - include_total: bool - ) -> Result<(Vec, Option), DomainError>; - - /// Renombra una carpeta - async fn rename_folder(&self, id: &str, new_name: String) -> Result; - - /// Mueve una carpeta a otro padre - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result; - - /// Elimina una carpeta - async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; - - /// Verifica si existe una carpeta en la ruta dada - async fn folder_exists(&self, storage_path: &StoragePath) -> Result; - - /// Obtiene la ruta de una carpeta - async fn get_folder_path(&self, id: &str) -> Result; -} +/// Blanket implementation: cualquier tipo que implemente ambos ports +/// es automáticamente un FileStoragePort. +impl FileStoragePort for T {} + +/// Puerto secundario para persistencia de carpetas (application layer). +/// +/// Tiene la misma firma que `FolderRepository` del dominio. +/// Las implementaciones concretas deben implementar `FolderRepository`, +/// obteniendo `FolderStoragePort` automáticamente vía blanket impl. +pub trait FolderStoragePort: FolderRepository {} + +/// Blanket implementation: cualquier tipo que implemente FolderRepository +/// es automáticamente un FolderStoragePort. +impl FolderStoragePort for T {} /// Puerto secundario para mapeo de IDs #[async_trait] diff --git a/src/application/ports/recent_ports.rs b/src/application/ports/recent_ports.rs index 47d79e10..ffa27593 100644 --- a/src/application/ports/recent_ports.rs +++ b/src/application/ports/recent_ports.rs @@ -16,4 +16,30 @@ pub trait RecentItemsUseCase: Send + Sync { /// Limpiar toda la lista de elementos recientes async fn clear_recent_items(&self, user_id: &str) -> Result<()>; +} + +// ───────────────────────────────────────────────────── +// Outbound port — persistence abstraction +// ───────────────────────────────────────────────────── + +/// Puerto secundario (outbound) para persistencia de elementos recientes. +/// +/// Abstrae el acceso a la tabla `auth.user_recent_files` para que +/// `RecentService` no dependa directamente de `PgPool`. +#[async_trait] +pub trait RecentItemsRepositoryPort: Send + Sync + 'static { + /// Obtiene los últimos elementos recientes de un usuario (ordenados por fecha desc). + async fn get_recent_items(&self, user_id: &str, limit: i32) -> Result>; + + /// Registra/actualiza el acceso a un ítem (upsert por user+item+type). + async fn upsert_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>; + + /// Elimina un ítem de recientes. Devuelve `true` si existía. + async fn remove_item(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; + + /// Elimina todos los elementos recientes de un usuario. + async fn clear_all(&self, user_id: &str) -> Result<()>; + + /// Elimina elementos que excedan `max_items` (los más antiguos). + async fn prune(&self, user_id: &str, max_items: i32) -> Result<()>; } \ No newline at end of file diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 46793171..ca679015 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -8,26 +8,65 @@ use crate::domain::entities::file::File; use crate::domain::services::path_service::StoragePath; use crate::common::errors::DomainError; -/// Puerto secundario para lectura de archivos +// Re-export domain repository traits for backward compatibility. +// The canonical definitions now live in domain/repositories/. +pub use crate::domain::repositories::file_repository::{FileReadRepository, FileWriteRepository, FileRepository}; +pub use crate::domain::repositories::folder_repository::FolderRepository; + +// ───────────────────────────────────────────────────── +// FileReadPort — application-layer alias for FileReadRepository +// ───────────────────────────────────────────────────── + +/// Puerto secundario para **lectura** de archivos. +/// +/// Encapsula toda operación que consulta estado sin modificarlo: +/// get, list, content, stream, mmap, range, resolución de rutas. #[async_trait] pub trait FileReadPort: Send + Sync + 'static { - /// Obtiene un archivo por su ID + /// Obtiene un archivo por su ID. async fn get_file(&self, id: &str) -> Result; - - /// Lista archivos en una carpeta + + /// Lista archivos en una carpeta. async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; - - /// Obtiene contenido de archivo como bytes + + /// Obtiene contenido completo como bytes (solo archivos pequeños/medianos). async fn get_file_content(&self, id: &str) -> Result, DomainError>; - - /// Obtiene contenido de archivo como stream - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError>; + + /// Obtiene contenido como stream (ideal para archivos grandes). + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError>; + + /// Stream de un rango de bytes (HTTP Range Requests, video seek). + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError>; + + /// Memory-map de archivo para acceso zero-copy (10–100 MB). + async fn get_file_mmap(&self, id: &str) -> Result; + + /// Obtiene la ruta de almacenamiento lógica de un archivo. + async fn get_file_path(&self, id: &str) -> Result; + + /// Obtiene el ID de la carpeta padre a partir de una ruta (WebDAV). + async fn get_parent_folder_id(&self, path: &str) -> Result; } -/// Puerto secundario para escritura de archivos +// ───────────────────────────────────────────────────── +// FileWritePort — all write / mutate operations +// ───────────────────────────────────────────────────── + +/// Puerto secundario para **escritura** de archivos. +/// +/// Cubre: upload (buffered + streaming), move, delete, update, +/// y el registro diferido para write-behind cache. #[async_trait] pub trait FileWritePort: Send + Sync + 'static { - /// Guarda un nuevo archivo desde bytes + /// Guarda un nuevo archivo desde bytes. async fn save_file( &self, name: String, @@ -35,26 +74,63 @@ pub trait FileWritePort: Send + Sync + 'static { content_type: String, content: Vec, ) -> Result; - - /// Mueve un archivo a otra carpeta - async fn move_file(&self, file_id: &str, target_folder_id: Option) -> Result; - - /// Elimina un archivo + + /// Upload en streaming — escribe chunks a disco sin acumular en RAM. + async fn save_file_from_stream( + &self, + name: String, + folder_id: Option, + content_type: String, + stream: std::pin::Pin> + Send>>, + ) -> Result; + + /// Mueve un archivo a otra carpeta. + async fn move_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result; + + /// Elimina un archivo. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; - - /// Obtiene detalles de una carpeta - async fn get_folder_details(&self, folder_id: &str) -> Result; - - /// Obtiene la ruta de una carpeta como string - async fn get_folder_path_str(&self, folder_id: &str) -> Result; + + /// Actualiza el contenido de un archivo existente. + async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError>; + + /// Registra metadatos de archivo SIN escribir contenido a disco (write-behind). + /// + /// Devuelve `(File, PathBuf)` donde `PathBuf` es la ruta destino para la + /// escritura diferida que realizará el `WriteBehindCache`. + async fn register_file_deferred( + &self, + name: String, + folder_id: Option, + content_type: String, + size: u64, + ) -> Result<(File, PathBuf), DomainError>; + + // ── Trash operations ── + + /// Mueve un archivo a la papelera + async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; + + /// Restaura un archivo desde la papelera a su ubicación original + async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError>; + + /// Elimina un archivo permanentemente (usado por la papelera) + async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError>; } +// ───────────────────────────────────────────────────── +// Auxiliary ports (unchanged) +// ───────────────────────────────────────────────────── + /// Puerto secundario para resolución de rutas de archivos #[async_trait] pub trait FilePathResolutionPort: Send + Sync + 'static { /// Obtiene la ruta de almacenamiento de un archivo async fn get_file_path(&self, id: &str) -> Result; - + /// Resuelve una ruta de dominio a una ruta física fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf; } @@ -64,7 +140,7 @@ pub trait FilePathResolutionPort: Send + Sync + 'static { pub trait StorageVerificationPort: Send + Sync + 'static { /// Verifica si existe un archivo en la ruta dada async fn file_exists(&self, storage_path: &StoragePath) -> Result; - + /// Verifica si existe un directorio en la ruta dada async fn directory_exists(&self, storage_path: &StoragePath) -> Result; } @@ -81,7 +157,7 @@ pub trait DirectoryManagementPort: Send + Sync + 'static { pub trait StorageUsagePort: Send + Sync + 'static { /// Actualiza estadísticas de uso de almacenamiento para un usuario async fn update_user_storage_usage(&self, user_id: &str) -> Result; - + /// Actualiza estadísticas de uso de almacenamiento para todos los usuarios async fn update_all_users_storage_usage(&self) -> Result<(), DomainError>; } diff --git a/src/application/ports/thumbnail_ports.rs b/src/application/ports/thumbnail_ports.rs new file mode 100644 index 00000000..6a0e3be0 --- /dev/null +++ b/src/application/ports/thumbnail_ports.rs @@ -0,0 +1,91 @@ +//! Thumbnail Port - Application layer abstraction for thumbnail generation. +//! +//! This module defines the port (trait) for thumbnail operations, +//! keeping the application and interface layers independent of specific +//! image processing implementations. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use async_trait::async_trait; +use bytes::Bytes; +use crate::common::errors::DomainError; + +/// Thumbnail sizes supported by the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ThumbnailSize { + /// Small icon for file listings (150×150) + Icon, + /// Medium preview for gallery view (400×400) + Preview, + /// Large preview for detail view (800×800) + Large, +} + +impl ThumbnailSize { + /// Get the maximum dimension for this size. + pub fn max_dimension(&self) -> u32 { + match self { + ThumbnailSize::Icon => 150, + ThumbnailSize::Preview => 400, + ThumbnailSize::Large => 800, + } + } + + /// Get the directory name for this size. + pub fn dir_name(&self) -> &'static str { + match self { + ThumbnailSize::Icon => "icon", + ThumbnailSize::Preview => "preview", + ThumbnailSize::Large => "large", + } + } + + /// Get all thumbnail sizes. + pub fn all() -> &'static [ThumbnailSize] { + &[ThumbnailSize::Icon, ThumbnailSize::Preview, ThumbnailSize::Large] + } +} + +/// Statistics about the thumbnail cache. +#[derive(Debug, Clone)] +pub struct ThumbnailStatsDto { + pub cached_thumbnails: usize, + pub cache_size_bytes: usize, + pub max_cache_bytes: usize, +} + +/// Port for thumbnail generation and retrieval. +/// +/// Implementations handle the actual image processing, caching, +/// and storage of thumbnails, while the application layer only +/// interacts through this abstraction. +#[async_trait] +pub trait ThumbnailPort: Send + Sync + 'static { + /// Check if a file is an image that can have thumbnails. + fn is_supported_image(&self, mime_type: &str) -> bool; + + /// Get a thumbnail, generating it on-demand if needed. + /// + /// Returns the thumbnail bytes in WebP format. + async fn get_thumbnail( + &self, + file_id: &str, + size: ThumbnailSize, + original_path: &Path, + ) -> Result; + + /// Generate all thumbnail sizes for a file in the background. + /// + /// Called after file upload to pre-generate thumbnails. + fn generate_all_sizes_background( + self: Arc, + file_id: String, + original_path: PathBuf, + ); + + /// Delete all thumbnails for a file. + async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>; + + /// Get cache statistics. + async fn get_stats(&self) -> ThumbnailStatsDto; +} diff --git a/src/application/ports/transcode_ports.rs b/src/application/ports/transcode_ports.rs new file mode 100644 index 00000000..e6df889c --- /dev/null +++ b/src/application/ports/transcode_ports.rs @@ -0,0 +1,106 @@ +//! Image Transcode Port - Application layer abstraction for image transcoding. +//! +//! This module defines the port (trait) for on-demand image format conversion +//! (e.g., JPEG/PNG → WebP), keeping the application and interface layers +//! independent of specific image processing implementations. + +use async_trait::async_trait; +use bytes::Bytes; +use crate::common::errors::DomainError; + +/// Supported output formats for image transcoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OutputFormat { + /// WebP format — best current browser support with good compression. + WebP, + // Future: Avif, JpegXl +} + +impl OutputFormat { + /// Get the file extension for this format. + pub fn extension(&self) -> &'static str { + match self { + OutputFormat::WebP => "webp", + } + } + + /// Get the MIME type for this format. + pub fn mime_type(&self) -> &'static str { + match self { + OutputFormat::WebP => "image/webp", + } + } +} + +/// Browser image format capabilities detected from the Accept header. +#[derive(Debug)] +pub struct BrowserCapabilities { + pub supports_webp: bool, + pub supports_avif: bool, +} + +impl BrowserCapabilities { + /// Parse the HTTP Accept header to determine browser image format support. + pub fn from_accept_header(accept: Option<&str>) -> Self { + let accept = accept.unwrap_or(""); + Self { + supports_webp: accept.contains("image/webp"), + supports_avif: accept.contains("image/avif"), + } + } + + /// Get the best output format supported by the browser. + pub fn best_format(&self) -> Option { + if self.supports_webp { + Some(OutputFormat::WebP) + } else { + None + } + } +} + +/// Statistics about transcoding operations. +#[derive(Debug, Default, Clone)] +pub struct TranscodeStatsDto { + pub cache_hits: u64, + pub disk_hits: u64, + pub transcodes: u64, + pub bytes_saved: u64, + pub transcode_errors: u64, +} + +/// Port for image transcoding operations. +/// +/// Implementations handle the actual image conversion, caching, +/// and format detection, while the application layer only interacts +/// through this abstraction. +#[async_trait] +pub trait ImageTranscodePort: Send + Sync + 'static { + /// Check if a MIME type can be transcoded. + fn can_transcode(&self, mime_type: &str) -> bool; + + /// Check if transcoding should be attempted based on file size and type. + fn should_transcode(&self, mime_type: &str, file_size: u64) -> bool; + + /// Get a transcoded version of an image. + /// + /// Returns `(content, mime_type, was_transcoded)`. + /// If transcoding is not beneficial (output larger than input), returns the + /// original content with `was_transcoded = false`. + async fn get_transcoded( + &self, + file_id: &str, + original_content: &[u8], + original_mime: &str, + target_format: OutputFormat, + ) -> Result<(Bytes, String, bool), DomainError>; + + /// Invalidate cached transcodes for a file. + async fn invalidate(&self, file_id: &str); + + /// Get transcoding statistics. + async fn get_stats(&self) -> TranscodeStatsDto; + + /// Clear all caches. + async fn clear_cache(&self) -> Result<(), DomainError>; +} diff --git a/src/application/ports/zip_ports.rs b/src/application/ports/zip_ports.rs new file mode 100644 index 00000000..9af78ac5 --- /dev/null +++ b/src/application/ports/zip_ports.rs @@ -0,0 +1,24 @@ +//! ZIP Port - Application layer abstraction for ZIP archive creation. +//! +//! This module defines the port (trait) for ZIP operations, +//! keeping the interface layer independent of specific ZIP +//! implementation details. + +use async_trait::async_trait; +use crate::common::errors::DomainError; + +/// Port for ZIP archive operations. +/// +/// Implementations handle the actual ZIP file creation, compression, +/// and recursive folder traversal. +#[async_trait] +pub trait ZipPort: Send + Sync + 'static { + /// Create a ZIP archive containing the contents of a folder (recursively). + /// + /// Returns the ZIP file bytes. + async fn create_folder_zip( + &self, + folder_id: &str, + folder_name: &str, + ) -> Result, DomainError>; +} diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 16fec11a..da76bbf0 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -4,7 +4,7 @@ use tokio::sync::Semaphore; use tracing::info; use thiserror::Error; -use crate::application::services::file_service::FileService; +use crate::application::ports::file_ports::{FileRetrievalUseCase, FileManagementUseCase}; use crate::application::services::folder_service::FolderService; use crate::common::errors::DomainError; use crate::common::config::AppConfig; @@ -59,7 +59,8 @@ pub struct BatchStats { /// Servicio de operaciones por lotes pub struct BatchOperationService { - file_service: Arc, + file_retrieval: Arc, + file_management: Arc, folder_service: Arc, config: AppConfig, semaphore: Arc, @@ -68,7 +69,8 @@ pub struct BatchOperationService { impl BatchOperationService { /// Crea una nueva instancia del servicio de operaciones por lotes pub fn new( - file_service: Arc, + file_retrieval: Arc, + file_management: Arc, folder_service: Arc, config: AppConfig ) -> Self { @@ -76,7 +78,8 @@ impl BatchOperationService { let max_concurrency = config.concurrency.max_concurrent_files; Self { - file_service, + file_retrieval, + file_management, folder_service, config, semaphore: Arc::new(Semaphore::new(max_concurrency)), @@ -85,10 +88,11 @@ impl BatchOperationService { /// Crea una nueva instancia con la configuración por defecto pub fn default( - file_service: Arc, + file_retrieval: Arc, + file_management: Arc, folder_service: Arc ) -> Self { - Self::new(file_service, folder_service, AppConfig::default()) + Self::new(file_retrieval, file_management, folder_service, AppConfig::default()) } /// Copia múltiples archivos en paralelo @@ -112,7 +116,7 @@ impl BatchOperationService { // Definir la operación a realizar para cada archivo let operations = file_ids.into_iter().map(|file_id| { - let file_service = self.file_service.clone(); + let mgmt = self.file_management.clone(); let target_folder = target_folder_id.clone(); let semaphore = self.semaphore.clone(); @@ -120,7 +124,7 @@ impl BatchOperationService { // Adquirir permiso del semáforo let permit = semaphore.acquire().await.unwrap(); - let copy_result = file_service.move_file(&file_id, target_folder.clone()).await; + let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await; // Liberar el permiso explícitamente (también se libera al hacer drop) drop(permit); @@ -183,7 +187,7 @@ impl BatchOperationService { // Definir la operación a realizar para cada archivo let operations = file_ids.into_iter().map(|file_id| { - let file_service = self.file_service.clone(); + let mgmt = self.file_management.clone(); let target_folder = target_folder_id.clone(); let semaphore = self.semaphore.clone(); @@ -191,7 +195,7 @@ impl BatchOperationService { // Adquirir permiso del semáforo let permit = semaphore.acquire().await.unwrap(); - let move_result = file_service.move_file(&file_id, target_folder.clone()).await; + let move_result = mgmt.move_file(&file_id, target_folder.clone()).await; // Liberar el permiso explícitamente drop(permit); @@ -253,7 +257,7 @@ impl BatchOperationService { // Definir la operación a realizar para cada archivo let operations = file_ids.into_iter().map(|file_id| { - let file_service = self.file_service.clone(); + let mgmt = self.file_management.clone(); let semaphore = self.semaphore.clone(); let id_clone = file_id.clone(); @@ -261,7 +265,7 @@ impl BatchOperationService { // Adquirir permiso del semáforo let permit = semaphore.acquire().await.unwrap(); - let delete_result = file_service.delete_file(&file_id).await; + let delete_result = mgmt.delete_file(&file_id).await; // Liberar el permiso explícitamente drop(permit); @@ -323,14 +327,14 @@ impl BatchOperationService { // Definir la operación a realizar para cada archivo let operations = file_ids.into_iter().map(|file_id| { - let file_service = self.file_service.clone(); + let retrieval = self.file_retrieval.clone(); let semaphore = self.semaphore.clone(); async move { // Adquirir permiso del semáforo let permit = semaphore.acquire().await.unwrap(); - let get_result = file_service.get_file(&file_id).await; + let get_result = retrieval.get_file(&file_id).await; // Liberar el permiso explícitamente drop(permit); @@ -665,87 +669,17 @@ impl BatchOperationService { mod tests { use super::*; use std::sync::Arc; - use tokio::sync::Mutex; - use mockall::predicate::*; - use mockall::mock; - - // Crear mocks para los servicios - mock! { - FileSvcMock {} - - #[async_trait] - impl FileService for FileSvcMock { - async fn create_file(&self, name: String, folder_id: Option, content_type: String, content: Vec) -> Result; - async fn get_file(&self, id: &str) -> Result; - async fn delete_file(&self, id: &str) -> Result<(), DomainError>; - async fn move_file(&self, id: &str, target_folder_id: Option) -> Result; - async fn copy_file(&self, id: &str, target_folder_id: Option) -> Result; - async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; - async fn get_file_content(&self, id: &str) -> Result, DomainError>; - } - } - - mock! { - FolderSvcMock {} - - #[async_trait] - impl FolderService for FolderSvcMock { - async fn create_folder(&self, name: String, parent_id: Option) -> Result; - async fn get_folder(&self, id: &str) -> Result; - async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; - async fn delete_folder_recursive(&self, id: &str) -> Result<(), DomainError>; - async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - async fn move_folder(&self, id: &str, target_parent_id: Option) -> Result; - } - } - - #[tokio::test] - async fn test_batch_delete_files() { - // Crear mocks - let mut file_service = MockFileSvcMock::new(); - - // Configurar comportamiento esperado - file_service.expect_delete_file() - .times(3) - .returning(|id| { - if id == "error-id" { - Err(DomainError::not_found("FileService", "File not found")) - } else { - Ok(()) - } - }); - - // Crear el servicio de batch con los mocks - let batch_service = BatchOperationService::new( - Arc::new(file_service), - Arc::new(MockFolderSvcMock::new()), - AppConfig::default() - ); - - // Ejecutar la operación de batch - let file_ids = vec![ - "id1".to_string(), - "id2".to_string(), - "error-id".to_string() - ]; - - let result = batch_service.delete_files(file_ids).await.unwrap(); - - // Verificar los resultados - assert_eq!(result.stats.total, 3); - assert_eq!(result.stats.successful, 2); - assert_eq!(result.stats.failed, 1); - assert_eq!(result.successful.len(), 2); - assert_eq!(result.failed.len(), 1); - assert_eq!(result.failed[0].0, "error-id"); - } + use crate::common::stubs::{StubFileRetrievalUseCase, StubFileManagementUseCase}; #[tokio::test] async fn test_generic_batch_operation() { - // Crear el servicio de batch + // Crear el servicio de batch with stubs let batch_service = BatchOperationService::new( - Arc::new(MockFileSvcMock::new()), - Arc::new(MockFolderSvcMock::new()), + Arc::new(StubFileRetrievalUseCase), + Arc::new(StubFileManagementUseCase), + Arc::new(FolderService::new( + Arc::new(crate::common::stubs::StubFolderStoragePort) + )), AppConfig::default() ); @@ -759,7 +693,7 @@ mod tests { Ok(item * 2) } else { // Simular error para números impares - Err(DomainError::invalid_input("Test", "Odd number not allowed")) + Err(DomainError::validation_error("Odd number not allowed")) } }; diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index bbef000e..e144d1d9 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -44,7 +44,7 @@ impl ContactService { .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; // Check if user is owner - if address_book.owner_id == user_id { + if address_book.owner_id() == user_id { return Ok(address_book); } @@ -55,7 +55,7 @@ impl ContactService { } // Check if address book is public - if address_book.is_public { + if address_book.is_public() { return Ok(address_book); } @@ -68,7 +68,7 @@ impl ContactService { .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; // Check if user is owner - if address_book.owner_id == user_id { + if address_book.owner_id() == user_id { return Ok(address_book); } @@ -93,12 +93,12 @@ impl ContactService { let line = lines[i].trim(); if line.starts_with("FN:") { - contact.full_name = Some(line[3..].to_string()); + contact.set_full_name(Some(line[3..].to_string())); } else if line.starts_with("N:") { let parts: Vec<&str> = line[2..].split(';').collect(); if parts.len() >= 2 { - contact.last_name = Some(parts[0].to_string()); - contact.first_name = Some(parts[1].to_string()); + contact.set_last_name(Some(parts[0].to_string())); + contact.set_first_name(Some(parts[1].to_string())); } } else if line.starts_with("EMAIL") { let value = line.split(':').nth(1).unwrap_or(""); @@ -111,10 +111,10 @@ impl ContactService { "other" }; - contact.email.push(Email { + contact.push_email(Email { email: value.to_string(), r#type: email_type.to_string(), - is_primary: contact.email.is_empty(), // First one is primary + is_primary: contact.email_is_empty(), // First one is primary }); } } else if line.starts_with("TEL") { @@ -132,26 +132,26 @@ impl ContactService { "other" }; - contact.phone.push(Phone { + contact.push_phone(Phone { number: value.to_string(), r#type: phone_type.to_string(), - is_primary: contact.phone.is_empty(), // First one is primary + is_primary: contact.phone_is_empty(), // First one is primary }); } } else if line.starts_with("ORG:") { - contact.organization = Some(line[4..].to_string()); + contact.set_organization(Some(line[4..].to_string())); } else if line.starts_with("TITLE:") { - contact.title = Some(line[6..].to_string()); + contact.set_title(Some(line[6..].to_string())); } else if line.starts_with("NOTE:") { - contact.notes = Some(line[5..].to_string()); + contact.set_notes(Some(line[5..].to_string())); } else if line.starts_with("UID:") { - contact.uid = line[4..].to_string(); + contact.set_uid(line[4..].to_string()); } } // Store the original vCard data - contact.vcard = vcard_data.to_string(); - contact.etag = Uuid::new_v4().to_string(); + contact.set_vcard(vcard_data.to_string()); + contact.set_etag(Uuid::new_v4().to_string()); Ok(contact) } @@ -160,26 +160,26 @@ impl ContactService { let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); // UID - vcard.push_str(&format!("UID:{}\r\n", contact.uid)); + vcard.push_str(&format!("UID:{}\r\n", contact.uid())); // Name fields - if let Some(full_name) = &contact.full_name { + if let Some(full_name) = contact.full_name() { vcard.push_str(&format!("FN:{}\r\n", full_name)); } - let last_name = contact.last_name.clone().unwrap_or_default(); - let first_name = contact.first_name.clone().unwrap_or_default(); + let last_name = contact.last_name().unwrap_or_default().to_string(); + let first_name = contact.first_name().unwrap_or_default().to_string(); vcard.push_str(&format!("N:{};{};;;\r\n", last_name, first_name)); // Email addresses - for email in &contact.email { + for email in contact.email() { vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n", email.r#type.to_uppercase(), email.email)); } // Phone numbers - for phone in &contact.phone { + for phone in contact.phone() { let tel_type = match phone.r#type.as_str() { "mobile" => "CELL", "home" => "HOME", @@ -191,7 +191,7 @@ impl ContactService { } // Addresses - for addr in &contact.address { + for addr in contact.address() { let addr_type = addr.r#type.to_uppercase(); let street = addr.street.clone().unwrap_or_default(); let city = addr.city.clone().unwrap_or_default(); @@ -204,27 +204,27 @@ impl ContactService { } // Organization - if let Some(org) = &contact.organization { + if let Some(org) = contact.organization() { vcard.push_str(&format!("ORG:{}\r\n", org)); } // Title - if let Some(title) = &contact.title { + if let Some(title) = contact.title() { vcard.push_str(&format!("TITLE:{}\r\n", title)); } // Notes - if let Some(notes) = &contact.notes { + if let Some(notes) = contact.notes() { vcard.push_str(&format!("NOTE:{}\r\n", notes)); } // Birthday - if let Some(birthday) = &contact.birthday { + if let Some(birthday) = contact.birthday() { vcard.push_str(&format!("BDAY:{}\r\n", birthday.format("%Y%m%d"))); } // Revision (last update) - vcard.push_str(&format!("REV:{}\r\n", contact.updated_at.format("%Y%m%dT%H%M%SZ"))); + vcard.push_str(&format!("REV:{}\r\n", contact.updated_at().format("%Y%m%dT%H%M%SZ"))); vcard.push_str("END:VCARD\r\n"); @@ -235,19 +235,13 @@ impl ContactService { #[async_trait] impl AddressBookUseCase for ContactService { async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result { - let id = Uuid::new_v4(); - let now = Utc::now(); - - let address_book = AddressBook { - id, - name: dto.name, - owner_id: dto.owner_id, - description: dto.description, - color: dto.color, - is_public: dto.is_public.unwrap_or(false), - created_at: now, - updated_at: now, - }; + let address_book = AddressBook::new( + dto.name, + dto.owner_id, + dto.description, + dto.color, + dto.is_public.unwrap_or(false), + ); let created_address_book = self.address_book_repository.create_address_book(address_book).await?; Ok(AddressBookDto::from(created_address_book)) @@ -261,16 +255,16 @@ impl AddressBookUseCase for ContactService { let address_book = self.check_address_book_write_access(&id, &update.user_id).await?; // Apply updates - let updated_address_book = AddressBook { + let updated_address_book = AddressBook::from_raw( id, - name: update.name.unwrap_or(address_book.name), - owner_id: address_book.owner_id, - description: update.description.or(address_book.description), - color: update.color.or(address_book.color), - is_public: update.is_public.unwrap_or(address_book.is_public), - created_at: address_book.created_at, - updated_at: Utc::now(), - }; + update.name.unwrap_or_else(|| address_book.name().to_string()), + address_book.owner_id().to_string(), + update.description.or_else(|| address_book.description().map(|s| s.to_string())), + update.color.or_else(|| address_book.color().map(|s| s.to_string())), + update.is_public.unwrap_or(address_book.is_public()), + *address_book.created_at(), + Utc::now(), + ); let result = self.address_book_repository.update_address_book(updated_address_book).await?; Ok(AddressBookDto::from(result)) @@ -285,7 +279,7 @@ impl AddressBookUseCase for ContactService { .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::unauthorized("Only the owner can delete an address book")); } @@ -315,16 +309,16 @@ impl AddressBookUseCase for ContactService { let mut address_book_map = std::collections::HashMap::new(); for address_book in owned_address_books { - address_book_map.insert(address_book.id, address_book); + address_book_map.insert(*address_book.id(), address_book); } for address_book in shared_address_books { - address_book_map.insert(address_book.id, address_book); + address_book_map.insert(*address_book.id(), address_book); } for address_book in public_address_books { - if address_book.owner_id != user_id && !address_book_map.contains_key(&address_book.id) { - address_book_map.insert(address_book.id, address_book); + if address_book.owner_id() != user_id && !address_book_map.contains_key(address_book.id()) { + address_book_map.insert(*address_book.id(), address_book); } } @@ -351,7 +345,7 @@ impl AddressBookUseCase for ContactService { .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::unauthorized("Only the owner can share an address book")); } @@ -373,7 +367,7 @@ impl AddressBookUseCase for ContactService { .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::unauthorized("Only the owner can unshare an address book")); } @@ -390,7 +384,7 @@ impl AddressBookUseCase for ContactService { .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::unauthorized("Only the owner can view address book shares")); } @@ -408,10 +402,6 @@ impl ContactUseCase for ContactService { // Check if user has write access to the address book self.check_address_book_write_access(&address_book_id, &dto.user_id).await?; - let id = Uuid::new_v4(); - let now = Utc::now(); - let uid = format!("{}@oxicloud", id); - // Convert DTOs to domain entities let email: Vec = dto.email.into_iter() .map(|e| Email { @@ -441,33 +431,28 @@ impl ContactUseCase for ContactService { }) .collect(); - let contact = Contact { - id, + let mut contact = Contact::new( address_book_id, - uid, - full_name: dto.full_name, - first_name: dto.first_name, - last_name: dto.last_name, - nickname: dto.nickname, + dto.full_name, + dto.first_name, + dto.last_name, + dto.nickname, email, phone, address, - organization: dto.organization, - title: dto.title, - notes: dto.notes, - photo_url: dto.photo_url, - birthday: dto.birthday, - anniversary: dto.anniversary, - vcard: String::new(), // Will be generated after creation - etag: Uuid::new_v4().to_string(), - created_at: now, - updated_at: now, - }; + dto.organization, + dto.title, + dto.notes, + dto.photo_url, + dto.birthday, + dto.anniversary, + String::new(), // Will be generated after creation + ); // Generate vCard data let vcard = self.generate_vcard(&contact); - let mut contact_with_vcard = contact; - contact_with_vcard.vcard = vcard; + contact.set_vcard(vcard); + let contact_with_vcard = contact; // Create the contact let created_contact = self.contact_repository.create_contact(contact_with_vcard).await?; @@ -485,17 +470,12 @@ impl ContactUseCase for ContactService { let mut contact = self.parse_vcard(&dto.vcard)?; // Set address book ID - contact.address_book_id = address_book_id; - - // Generate a new ID if needed - if contact.id == Uuid::nil() { - contact.id = Uuid::new_v4(); - } + contact.set_address_book_id(address_book_id); + // The contact was created with Contact::default() which generates a new ID // Set creation and update timestamps let now = Utc::now(); - contact.created_at = now; - contact.updated_at = now; + contact.set_updated_at(now); // Create the contact let created_contact = self.contact_repository.create_contact(contact).await?; @@ -512,7 +492,10 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&contact.address_book_id, &update.user_id).await?; + self.check_address_book_write_access(contact.address_book_id(), &update.user_id).await?; + + // Destructure contact into owned parts for updates + let parts = contact.into_parts(); // Convert DTO fields to domain entities let email = if let Some(email_dtos) = update.email { @@ -524,7 +507,7 @@ impl ContactUseCase for ContactService { }) .collect() } else { - contact.email + parts.email }; let phone = if let Some(phone_dtos) = update.phone { @@ -536,7 +519,7 @@ impl ContactUseCase for ContactService { }) .collect() } else { - contact.phone + parts.phone }; let address = if let Some(address_dtos) = update.address { @@ -552,37 +535,37 @@ impl ContactUseCase for ContactService { }) .collect() } else { - contact.address + parts.address }; // Update the contact object - let updated_contact = Contact { + let mut updated_contact = Contact::from_raw( id, - address_book_id: contact.address_book_id, - uid: contact.uid, - full_name: update.full_name.or(contact.full_name), - first_name: update.first_name.or(contact.first_name), - last_name: update.last_name.or(contact.last_name), - nickname: update.nickname.or(contact.nickname), + parts.address_book_id, + parts.uid, + update.full_name.or(parts.full_name), + update.first_name.or(parts.first_name), + update.last_name.or(parts.last_name), + update.nickname.or(parts.nickname), email, phone, address, - organization: update.organization.or(contact.organization), - title: update.title.or(contact.title), - notes: update.notes.or(contact.notes), - photo_url: update.photo_url.or(contact.photo_url), - birthday: update.birthday.or(contact.birthday), - anniversary: update.anniversary.or(contact.anniversary), - vcard: contact.vcard, // Will be regenerated - etag: Uuid::new_v4().to_string(), // Generate new ETag - created_at: contact.created_at, - updated_at: Utc::now(), - }; + update.organization.or(parts.organization), + update.title.or(parts.title), + update.notes.or(parts.notes), + update.photo_url.or(parts.photo_url), + update.birthday.or(parts.birthday), + update.anniversary.or(parts.anniversary), + parts.vcard, // Will be regenerated + Uuid::new_v4().to_string(), // Generate new ETag + parts.created_at, + Utc::now(), + ); // Generate new vCard data let vcard = self.generate_vcard(&updated_contact); - let mut contact_with_vcard = updated_contact; - contact_with_vcard.vcard = vcard; + updated_contact.set_vcard(vcard); + let contact_with_vcard = updated_contact; // Update the contact let result = self.contact_repository.update_contact(contact_with_vcard).await?; @@ -599,7 +582,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&contact.address_book_id, user_id).await?; + self.check_address_book_write_access(contact.address_book_id(), user_id).await?; // Delete the contact self.contact_repository.delete_contact(&id).await?; @@ -616,7 +599,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(&contact.address_book_id, user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id).await?; Ok(ContactDto::from(contact)) } @@ -656,16 +639,10 @@ impl ContactUseCase for ContactService { // Check if user has write access to the address book self.check_address_book_write_access(&address_book_id, &dto.user_id).await?; - let id = Uuid::new_v4(); - let now = Utc::now(); - - let group = ContactGroup { - id, + let group = ContactGroup::new( address_book_id, - name: dto.name, - created_at: now, - updated_at: now, - }; + dto.name, + ); let created_group = self.contact_group_repository.create_group(group).await?; Ok(ContactGroupDto::from(created_group)) @@ -681,16 +658,16 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&group.address_book_id, &update.user_id).await?; + self.check_address_book_write_access(group.address_book_id(), &update.user_id).await?; // Update the group - let updated_group = ContactGroup { + let updated_group = ContactGroup::from_raw( id, - address_book_id: group.address_book_id, - name: update.name, - created_at: group.created_at, - updated_at: Utc::now(), - }; + *group.address_book_id(), + update.name, + *group.created_at(), + Utc::now(), + ); let result = self.contact_group_repository.update_group(updated_group).await?; Ok(ContactGroupDto::from(result)) @@ -706,7 +683,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&group.address_book_id, user_id).await?; + self.check_address_book_write_access(group.address_book_id(), user_id).await?; // Delete the group self.contact_group_repository.delete_group(&id).await?; @@ -723,7 +700,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(&group.address_book_id, user_id).await?; + self.check_address_book_access(group.address_book_id(), user_id).await?; // Get the number of contacts in the group let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?; @@ -761,7 +738,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&group.address_book_id, user_id).await?; + self.check_address_book_write_access(group.address_book_id(), user_id).await?; // Add contact to group self.contact_group_repository.add_contact_to_group(&group_id, &contact_id).await?; @@ -781,7 +758,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&group.address_book_id, user_id).await?; + self.check_address_book_write_access(group.address_book_id(), user_id).await?; // Remove contact from group self.contact_group_repository.remove_contact_from_group(&group_id, &contact_id).await?; @@ -798,7 +775,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(&group.address_book_id, user_id).await?; + self.check_address_book_access(group.address_book_id(), user_id).await?; // Get contacts in group let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?; @@ -817,7 +794,7 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(&contact.address_book_id, user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id).await?; // Get groups for contact let groups = self.contact_group_repository.get_groups_for_contact(&id).await?; @@ -836,10 +813,10 @@ impl ContactUseCase for ContactService { .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(&contact.address_book_id, user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id).await?; // Return the vCard data - Ok(contact.vcard) + Ok(contact.vcard().to_string()) } async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { @@ -854,7 +831,7 @@ impl ContactUseCase for ContactService { // Convert to Vec<(id, vcard)> let vcards = contacts.into_iter() - .map(|contact| (contact.id.to_string(), contact.vcard)) + .map(|contact| (contact.id().to_string(), contact.vcard().to_string())) .collect(); Ok(vcards) diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index 9b1e203d..d3ba2754 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -1,21 +1,22 @@ use std::sync::Arc; use async_trait::async_trait; -use sqlx::{PgPool, Row}; -use tracing::{info, error}; -use uuid::Uuid; +use tracing::info; use crate::common::errors::{Result, DomainError, ErrorKind}; -use crate::application::ports::favorites_ports::FavoritesUseCase; +use crate::application::ports::favorites_ports::{FavoritesUseCase, FavoritesRepositoryPort}; use crate::application::dtos::favorites_dto::FavoriteItemDto; -/// Implementation of the FavoritesUseCase for managing user favorites +/// Implementation of the FavoritesUseCase for managing user favorites. +/// +/// Depends on `FavoritesRepositoryPort` (outbound port) instead of +/// accessing the database directly, following hexagonal architecture. pub struct FavoritesService { - db_pool: Arc, + repo: Arc, } impl FavoritesService { - /// Create a new FavoritesService with the given database pool - pub fn new(db_pool: Arc) -> Self { - Self { db_pool } + /// Create a new FavoritesService with the given repository port + pub fn new(repo: Arc) -> Self { + Self { repo } } } @@ -24,168 +25,43 @@ impl FavoritesUseCase for FavoritesService { /// Get all favorites for a user async fn get_favorites(&self, user_id: &str) -> Result> { info!("Getting favorites for user: {}", user_id); - - // Parse user ID as UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Execute raw query to avoid sqlx macros issues - let rows = sqlx::query( - r#" - SELECT - id::TEXT as "id", - user_id::TEXT as "user_id", - item_id as "item_id", - item_type as "item_type", - created_at as "created_at" - FROM auth.user_favorites - WHERE user_id = $1::TEXT - ORDER BY created_at DESC - "# - ) - .bind(user_uuid) - .fetch_all(&*self.db_pool) - .await - .map_err(|e| { - error!("Database error fetching favorites: {}", e); - DomainError::new( - ErrorKind::InternalError, - "Favorites", - format!("Failed to fetch favorites: {}", e) - ) - })?; - - // Map rows to DTOs - let mut favorites = Vec::with_capacity(rows.len()); - for row in rows { - favorites.push(FavoriteItemDto { - id: row.get("id"), - user_id: row.get("user_id"), - item_id: row.get("item_id"), - item_type: row.get("item_type"), - created_at: row.get("created_at"), - }); - } - + let favorites = self.repo.get_favorites(user_id).await?; info!("Retrieved {} favorites for user {}", favorites.len(), user_id); Ok(favorites) } - + /// Add an item to user's favorites async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { info!("Adding {} '{}' to favorites for user {}", item_type, item_id, user_id); - - // Validate item_type + if item_type != "file" && item_type != "folder" { return Err(DomainError::new( ErrorKind::InvalidInput, "Favorites", - "Item type must be 'file' or 'folder'" + "Item type must be 'file' or 'folder'", )); } - - // Parse user ID as UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Execute raw query to avoid sqlx macros issues - sqlx::query( - r#" - INSERT INTO auth.user_favorites (user_id, item_id, item_type) - VALUES ($1::TEXT, $2, $3) - ON CONFLICT (user_id, item_id, item_type) DO NOTHING - "# - ) - .bind(user_uuid) - .bind(item_id) - .bind(item_type) - .execute(&*self.db_pool) - .await - .map_err(|e| { - error!("Database error adding favorite: {}", e); - DomainError::new( - ErrorKind::InternalError, - "Favorites", - format!("Failed to add to favorites: {}", e) - ) - })?; - + + self.repo.add_favorite(user_id, item_id, item_type).await?; info!("Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id); Ok(()) } - + /// Remove an item from user's favorites async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { info!("Removing {} '{}' from favorites for user {}", item_type, item_id, user_id); - - // Parse user ID as UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Execute raw query to avoid sqlx macros issues - let result = sqlx::query( - r#" - DELETE FROM auth.user_favorites - WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3 - "# - ) - .bind(user_uuid) - .bind(item_id) - .bind(item_type) - .execute(&*self.db_pool) - .await - .map_err(|e| { - error!("Database error removing favorite: {}", e); - DomainError::new( - ErrorKind::InternalError, - "Favorites", - format!("Failed to remove from favorites: {}", e) - ) - })?; - - let removed = result.rows_affected() > 0; + let removed = self.repo.remove_favorite(user_id, item_id, item_type).await?; info!( - "{} {} '{}' from favorites for user {}", + "{} {} '{}' from favorites for user {}", if removed { "Successfully removed" } else { "Did not find" }, - item_type, - item_id, - user_id + item_type, item_id, user_id ); - Ok(removed) } - + /// Check if an item is in user's favorites async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { info!("Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id); - - // Parse user ID as UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Execute raw query to avoid sqlx macros issues - let row = sqlx::query( - r#" - SELECT EXISTS ( - SELECT 1 FROM auth.user_favorites - WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3 - ) AS "is_favorite" - "# - ) - .bind(user_uuid) - .bind(item_id) - .bind(item_type) - .fetch_one(&*self.db_pool) - .await - .map_err(|e| { - error!("Database error checking favorite status: {}", e); - DomainError::new( - ErrorKind::InternalError, - "Favorites", - format!("Failed to check favorite status: {}", e) - ) - })?; - - // Get the boolean value from the row - let is_favorite: bool = row.try_get("is_favorite") - .unwrap_or(false); - - Ok(is_favorite) + self.repo.is_favorite(user_id, item_id, item_type).await } } \ No newline at end of file diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 19c08649..8afdfa2f 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -3,39 +3,155 @@ use async_trait::async_trait; use crate::application::dtos::file_dto::FileDto; use crate::application::ports::file_ports::FileManagementUseCase; -use crate::application::ports::storage_ports::FileWritePort; +use crate::application::ports::storage_ports::{FileWritePort, FileReadPort}; +use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::DomainError; +use tracing::{debug, info, warn, error}; -/// Service for file management operations +/// Service for file management operations (move, delete). +/// +/// The `delete_with_cleanup` method internalises: +/// 1. Content-hash computation for dedup tracking +/// 2. Trash-first soft-delete +/// 3. Fallback to permanent delete +/// 4. Dedup reference-count decrement pub struct FileManagementService { file_repository: Arc, + file_read: Option>, + trash_service: Option>, + dedup_service: Option>, } impl FileManagementService { - /// Creates a new file management service + /// Backward-compatible constructor (no trash, no dedup). pub fn new(file_repository: Arc) -> Self { - Self { file_repository } + Self { + file_repository, + file_read: None, + trash_service: None, + dedup_service: None, + } + } + + /// Full constructor with trash + dedup ports. + pub fn new_full( + file_repository: Arc, + file_read: Arc, + trash_service: Option>, + dedup_service: Arc, + ) -> Self { + Self { + file_repository, + file_read: Some(file_read), + trash_service, + dedup_service: Some(dedup_service), + } + } + + /// Setter for late-bound trash service. + pub fn with_trash_service(mut self, trash_service: Arc) -> Self { + self.trash_service = Some(trash_service); + self + } + + // ── private helpers ────────────────────────────────────────── + + /// Compute the content hash for dedup tracking. Returns `None` on failure. + async fn compute_content_hash(&self, id: &str) -> Option { + let dedup = self.dedup_service.as_ref()?; + let file_read = self.file_read.as_ref()?; + match file_read.get_file_content(id).await { + Ok(content) => { + let hash = dedup.hash_bytes(&content); + debug!("🔗 DEDUP: File {} has content hash: {}", id, &hash[..12]); + Some(hash) + } + Err(e) => { + debug!("Could not read file content for dedup: {}", e); + None + } + } + } + + /// Decrement dedup reference count; log result. + async fn decrement_dedup_ref(&self, hash: &str) { + let Some(dedup) = &self.dedup_service else { return }; + match dedup.remove_reference(hash).await { + Ok(true) => info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]), + Ok(false) => debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]), + Err(e) => warn!("⚠️ DEDUP: Failed to decrement reference: {}", e), + } } } #[async_trait] impl FileManagementUseCase for FileManagementService { - async fn move_file(&self, file_id: &str, folder_id: Option) -> Result { - tracing::info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id); - - let moved_file = self.file_repository.move_file(file_id, folder_id).await - .map_err(|e| { - tracing::error!("Error moving file (ID: {}): {}", file_id, e); - e - })?; - - tracing::info!("File moved successfully: {} (ID: {}) to folder: {:?}", - moved_file.name(), moved_file.id(), moved_file.folder_id()); - + async fn move_file( + &self, + file_id: &str, + folder_id: Option, + ) -> Result { + info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id); + + let moved_file = self.file_repository.move_file(file_id, folder_id).await.map_err(|e| { + error!("Error moving file (ID: {}): {}", file_id, e); + e + })?; + + info!( + "File moved successfully: {} (ID: {}) to folder: {:?}", + moved_file.name(), + moved_file.id(), + moved_file.folder_id() + ); + Ok(FileDto::from(moved_file)) } - + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { self.file_repository.delete_file(id).await } + + /// Smart delete: trash-first with dedup reference cleanup. + async fn delete_with_cleanup( + &self, + id: &str, + user_id: &str, + ) -> Result { + // Step 1: Compute content hash for dedup tracking + let content_hash = self.compute_content_hash(id).await; + + // Step 2: Try trash (soft delete) + if let Some(trash) = &self.trash_service { + info!("Moving file to trash: {}", id); + match trash.move_to_trash(id, "file", user_id).await { + Ok(_) => { + info!("File successfully moved to trash: {}", id); + if let Some(hash) = &content_hash { + self.decrement_dedup_ref(hash).await; + } + return Ok(true); // trashed + } + Err(err) => { + error!("Could not move file to trash: {:?}", err); + warn!("Falling back to permanent delete"); + // fall through + } + } + } else { + warn!("Trash service not available, using permanent delete"); + } + + // Step 3: Permanent delete + warn!("Permanently deleting file: {}", id); + self.file_repository.delete_file(id).await?; + info!("File permanently deleted: {}", id); + + if let Some(hash) = &content_hash { + self.decrement_dedup_ref(hash).await; + } + + Ok(false) // permanently deleted + } } \ No newline at end of file diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 55bdd5c8..88390e95 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -4,39 +4,258 @@ use bytes::Bytes; use futures::Stream; use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent}; use crate::application::ports::storage_ports::FileReadPort; +use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort}; +use crate::application::ports::transcode_ports::{ImageTranscodePort, OutputFormat}; use crate::common::errors::DomainError; +use tracing::{debug, info, warn}; + +/// Threshold below which files are served from RAM cache (10 MB). +const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024; +/// Threshold above which mmap is used instead of streaming (100 MB). +const MMAP_THRESHOLD: u64 = 100 * 1024 * 1024; /// Servicio para operaciones de recuperación de archivos +/// +/// Implements a multi-tier download strategy: +/// - Tier 0: Write-behind cache (just-uploaded files still in RAM) +/// - Tier 1: Hot cache + optional WebP transcoding (<10 MB) +/// - Tier 2: Memory-mapped I/O (10–100 MB) +/// - Tier 3: Streaming (≥100 MB) pub struct FileRetrievalService { - file_repository: Arc, + file_read: Arc, + write_behind: Option>, + content_cache: Option>, + transcode: Option>, } impl FileRetrievalService { - /// Crea un nuevo servicio de recuperación de archivos + /// Backward-compatible constructor (simple pass-through). pub fn new(file_repository: Arc) -> Self { - Self { file_repository } + Self { + file_read: file_repository, + write_behind: None, + content_cache: None, + transcode: None, + } + } + + /// Full constructor with all infrastructure ports. + pub fn new_full( + file_read: Arc, + write_behind: Arc, + content_cache: Arc, + transcode: Arc, + ) -> Self { + Self { + file_read, + write_behind: Some(write_behind), + content_cache: Some(content_cache), + transcode: Some(transcode), + } + } + + // ── private helpers ────────────────────────────────────────── + + /// Try to transcode image content to WebP and return transcoded variant. + async fn try_transcode( + &self, + id: &str, + content: &Bytes, + mime: &str, + file_size: u64, + accept_webp: bool, + ) -> Option<(Bytes, String)> { + if !accept_webp { + return None; + } + let transcode = self.transcode.as_ref()?; + if !transcode.should_transcode(mime, file_size) { + return None; + } + let format = OutputFormat::WebP; + match transcode.get_transcoded(id, content, mime, format).await { + Ok((transcoded, webp_mime, true)) => { + debug!( + "🖼️ WebP transcode: {} -> {} bytes ({:.0}% smaller)", + content.len(), + transcoded.len(), + (1.0 - transcoded.len() as f64 / content.len().max(1) as f64) * 100.0 + ); + Some((transcoded, webp_mime)) + } + _ => None, + } } } #[async_trait] impl FileRetrievalUseCase for FileRetrievalService { async fn get_file(&self, id: &str) -> Result { - let file = self.file_repository.get_file(id).await?; + let file = self.file_read.get_file(id).await?; Ok(FileDto::from(file)) } - + + async fn get_file_by_path(&self, path: &str) -> Result { + // Normalize the path (remove leading/trailing slashes) + let path = path.trim_start_matches('/').trim_end_matches('/'); + + // List all files and find the one with matching path + let all_files = self.list_files(None).await?; + + for file in all_files { + let file_path = file.path.trim_start_matches('/').trim_end_matches('/'); + if file_path == path + || file_path.ends_with(&format!("/{}", path)) + || path.ends_with(&format!("/{}", file_path)) + { + return Ok(file); + } + } + + Err(DomainError::not_found("File", format!("not found at path: {}", path))) + } + async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { - let files = self.file_repository.list_files(folder_id).await?; + let files = self.file_read.list_files(folder_id).await?; Ok(files.into_iter().map(FileDto::from).collect()) } - + async fn get_file_content(&self, id: &str) -> Result, DomainError> { - self.file_repository.get_file_content(id).await + self.file_read.get_file_content(id).await } - - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError> { - self.file_repository.get_file_stream(id).await + + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError> { + self.file_read.get_file_stream(id).await + } + + /// Multi-tier optimized download. + async fn get_file_optimized( + &self, + id: &str, + accept_webp: bool, + prefer_original: bool, + ) -> Result<(FileDto, OptimizedFileContent), DomainError> { + let file = self.file_read.get_file(id).await?; + let dto = FileDto::from(file); + let mime_type = dto.mime_type.clone(); + let file_size = dto.size; + let file_name = dto.name.clone(); + let modified_at = dto.modified_at.clone(); + let do_transcode = accept_webp && !prefer_original; + + // ── Tier 0: Write-behind cache ─────────────────────── + if let Some(wb) = &self.write_behind { + if let Some(pending) = wb.get_pending(id).await { + debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file_name, pending.len()); + let (data, mime) = if do_transcode { + if let Some((t, m)) = self.try_transcode(id, &pending, &mime_type, file_size, true).await { + (t, m) + } else { + (pending, mime_type.clone()) + } + } else { + (pending, mime_type.clone()) + }; + return Ok((dto, OptimizedFileContent::Bytes { + data, + mime_type: mime, + was_transcoded: do_transcode, + })); + } + } + + // ── Tier 1: Hot cache + transcode (<10 MB) ────────── + if file_size < CACHE_THRESHOLD { + // Check content cache first + if let Some(cache) = &self.content_cache { + if let Some((cached, _etag, _ct)) = cache.get(id).await { + debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file_name, cached.len()); + if do_transcode { + if let Some((t, m)) = self.try_transcode(id, &cached, &mime_type, file_size, true).await { + return Ok((dto, OptimizedFileContent::Bytes { + data: t, + mime_type: m, + was_transcoded: true, + })); + } + } + return Ok((dto, OptimizedFileContent::Bytes { + data: cached, + mime_type: mime_type.clone(), + was_transcoded: false, + })); + } + } + + // Cache miss – load from disk + debug!("💾 TIER 1 Cache MISS: {} – loading from disk", file_name); + let content = self.file_read.get_file_content(id).await?; + let content_bytes = Bytes::from(content); + + // Store in cache + if let Some(cache) = &self.content_cache { + let etag = format!("\"{}-{}\"", id, modified_at); + cache.put(id.to_string(), content_bytes.clone(), etag, mime_type.clone()).await; + } + + if do_transcode { + if let Some((t, m)) = self.try_transcode(id, &content_bytes, &mime_type, file_size, true).await { + return Ok((dto, OptimizedFileContent::Bytes { + data: t, + mime_type: m, + was_transcoded: true, + })); + } + } + return Ok((dto, OptimizedFileContent::Bytes { + data: content_bytes, + mime_type: mime_type.clone(), + was_transcoded: false, + })); + } + + // ── Tier 2: MMAP (10–100 MB) ──────────────────────── + if file_size < MMAP_THRESHOLD { + info!("🗺️ TIER 2 MMAP: {} ({} MB)", file_name, file_size / (1024 * 1024)); + match self.file_read.get_file_mmap(id).await { + Ok(mmap_content) => { + return Ok((dto, OptimizedFileContent::Mmap(mmap_content))); + } + Err(e) => { + warn!("MMAP failed, falling back to streaming: {}", e); + // fall through to streaming + } + } + } + + // ── Tier 3: Streaming (≥100 MB) ───────────────────── + info!("📡 TIER 3 STREAMING: {} ({} MB)", file_name, file_size / (1024 * 1024)); + match self.file_read.get_file_stream(id).await { + Ok(stream) => Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream)))), + Err(e) => { + warn!("Streaming failed, last-resort content load: {}", e); + let content = self.file_read.get_file_content(id).await?; + Ok((dto, OptimizedFileContent::Bytes { + data: Bytes::from(content), + mime_type: mime_type.clone(), + was_transcoded: false, + })) + } + } + } + + /// Range-based streaming for HTTP Range Requests. + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError> { + self.file_read.get_file_range_stream(id, start, end).await } } \ No newline at end of file diff --git a/src/application/services/file_service.rs b/src/application/services/file_service.rs deleted file mode 100644 index 6741246d..00000000 --- a/src/application/services/file_service.rs +++ /dev/null @@ -1,437 +0,0 @@ -use std::sync::Arc; -use thiserror::Error; -use async_trait::async_trait; - -use crate::domain::repositories::file_repository::FileRepositoryError; -use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::inbound::FileUseCase; -use crate::application::ports::outbound::FileStoragePort; -use crate::common::errors::DomainError; -use futures::Stream; -use bytes::Bytes; - -/** - * File service-specific error types. - * - * This enum represents the application-level errors that can occur during file operations, - * providing a translation layer between domain/infrastructure errors and application errors. - */ -#[derive(Debug, Error)] -pub enum FileServiceError { - /// Returned when a requested file cannot be found - #[error("File not found: {0}")] - NotFound(String), - - /// Returned when a file operation conflicts with existing files - #[error("File already exists: {0}")] - Conflict(String), - - /// Returned when file access fails due to permissions or I/O issues - #[error("File access error: {0}")] - AccessError(String), - - /// Returned when a file path is invalid - #[error("Invalid file path: {0}")] - InvalidPath(String), - - /// Generic internal error for unexpected failures - #[error("Internal error: {0}")] - InternalError(String), -} - -/** - * Converts repository errors to service errors. - * - * This implementation maps low-level repository errors to more - * application-appropriate error types, abstracting away the implementation details. - */ -impl From for FileServiceError { - fn from(err: FileRepositoryError) -> Self { - match err { - FileRepositoryError::NotFound(id) => FileServiceError::NotFound(id), - FileRepositoryError::AlreadyExists(path) => FileServiceError::Conflict(path), - FileRepositoryError::InvalidPath(path) => FileServiceError::InvalidPath(path), - FileRepositoryError::IoError(e) => FileServiceError::AccessError(e.to_string()), - FileRepositoryError::Timeout(msg) => FileServiceError::AccessError(format!("Operation timed out: {}", msg)), - _ => FileServiceError::InternalError(err.to_string()), - } - } -} - -/** - * Converts domain errors to service errors. - * - * This implementation ensures that general domain errors are properly translated - * to file service-specific errors while preserving their semantic meaning. - */ -impl From for FileServiceError { - fn from(err: DomainError) -> Self { - match err.kind { - crate::common::errors::ErrorKind::NotFound => FileServiceError::NotFound(err.to_string()), - crate::common::errors::ErrorKind::AlreadyExists => FileServiceError::Conflict(err.to_string()), - crate::common::errors::ErrorKind::InvalidInput => FileServiceError::InvalidPath(err.to_string()), - crate::common::errors::ErrorKind::AccessDenied => FileServiceError::AccessError(err.to_string()), - _ => FileServiceError::InternalError(err.to_string()), - } - } -} - -/** - * Converts service errors to domain errors. - * - * This implementation allows service errors to be propagated up the call stack as - * domain errors when crossing architectural boundaries. - */ -impl From for DomainError { - fn from(err: FileServiceError) -> Self { - match err { - FileServiceError::NotFound(id) => DomainError::not_found("File", id), - FileServiceError::Conflict(path) => DomainError::already_exists("File", path), - FileServiceError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)), - FileServiceError::AccessError(msg) => DomainError::access_denied("File", msg), - FileServiceError::InternalError(msg) => DomainError::internal_error("File", msg), - } - } -} - -/** - * Type alias for results of file service operations. - * - * Provides a convenient way to return either a successful value or a FileServiceError. - */ -pub type FileServiceResult = Result; - -/** - * Service component for file operations in the application layer. - * - * The FileService implements the application use cases related to files by orchestrating - * domain logic and infrastructure components. It acts as an adapter between the inbound - * ports (interfaces) and outbound ports (repositories), translating between DTOs and - * domain entities. - */ -pub struct FileService { - /// Repository responsible for file storage operations - file_repository: Arc, -} - -impl FileService { - /// Creates a new file service - pub fn new(file_repository: Arc) -> Self { - Self { file_repository } - } - - /// Creates a stub implementation for testing and middleware - pub fn new_stub() -> impl FileUseCase { - struct FileServiceStub; - - #[async_trait] - impl FileUseCase for FileServiceStub { - async fn upload_file( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> Result { - Ok(FileDto::empty()) - } - - async fn get_file(&self, _id: &str) -> Result { - Ok(FileDto::empty()) - } - - async fn get_file_by_path(&self, _path: &str) -> Result { - Ok(FileDto::empty()) - } - - async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result { - Ok(FileDto::empty()) - } - - async fn update_file(&self, _path: &str, _content: &[u8]) -> Result<(), DomainError> { - Ok(()) - } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { - Ok(vec![]) - } - - async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { - Ok(()) - } - - async fn get_file_content(&self, _id: &str) -> Result, DomainError> { - Ok(vec![]) - } - - async fn get_file_stream(&self, _id: &str) -> Result> + Send>, DomainError> { - let empty_stream = futures::stream::empty(); - Ok(Box::new(empty_stream)) - } - - async fn move_file(&self, _file_id: &str, _folder_id: Option) -> Result { - Ok(FileDto::empty()) - } - } - - FileServiceStub - } - - /// Uploads a new file from bytes - pub async fn upload_file_from_bytes( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> FileServiceResult - { - let file = self.file_repository.save_file(name, folder_id, content_type, content).await - .map_err(FileServiceError::from)?; - Ok(FileDto::from(file)) - } - - /// Uploads a file using streaming - writes directly to disk without memory accumulation - /// - /// This is the preferred method for large file uploads: - /// - Constant memory usage (~10MB) regardless of file size - /// - Better handling of slow connections - /// - Atomic writes with crash safety - pub async fn upload_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - stream: std::pin::Pin> + Send>>, - ) -> FileServiceResult - { - let file = self.file_repository.save_file_from_stream(name, folder_id, content_type, stream).await - .map_err(FileServiceError::from)?; - Ok(FileDto::from(file)) - } - - /// Registers a file for write-behind upload (ZERO LATENCY) - /// - /// This method: - /// 1. Creates file metadata and ID (~0.1ms) - /// 2. Returns immediately WITHOUT writing content to disk - /// 3. Caller must use WriteBehindCache to write content asynchronously - /// - /// Returns: (FileDto, PathBuf) where PathBuf is where content should be written - pub async fn register_file_deferred( - &self, - name: String, - folder_id: Option, - content_type: String, - size: u64, - ) -> FileServiceResult<(FileDto, std::path::PathBuf)> - { - let (file, path) = self.file_repository.register_file_deferred(name, folder_id, content_type, size).await - .map_err(FileServiceError::from)?; - Ok((FileDto::from(file), path)) - } - - /// Gets a file by ID - pub async fn get_file(&self, id: &str) -> FileServiceResult { - let file = self.file_repository.get_file(id).await - .map_err(FileServiceError::from)?; - Ok(FileDto::from(file)) - } - - /// Gets a file by path (needed for WebDAV) - pub async fn get_file_by_path(&self, path: &str) -> FileServiceResult { - // This is a simple implementation for WebDAV support - // First, normalize the path (remove leading/trailing slashes) - let path = path.trim_start_matches('/').trim_end_matches('/'); - - // List all files and find the one with matching path - let all_files = self.list_files(None).await?; - - for file in all_files { - let file_path = file.path.trim_start_matches('/').trim_end_matches('/'); - if file_path == path || file_path.ends_with(&format!("/{}", path)) || path.ends_with(&format!("/{}", file_path)) { - return Ok(file); - } - } - - // If no file found, return an error - Err(FileServiceError::NotFound(format!("File not found at path: {}", path))) - } - - /// Creates or updates a file at a specific path (needed for WebDAV) - pub async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> FileServiceResult { - // Get parent folder ID if parent path is not empty - let parent_id = if !parent_path.is_empty() { - match self.file_repository.get_parent_folder_id(parent_path).await { - Ok(id) => Some(id), - Err(_) => None // If parent doesn't exist, use root - } - } else { - None // Root folder - }; - - // Save the file with the provided filename and parent folder - let file = self.file_repository.save_file( - filename.to_string(), - parent_id, - content_type.to_string(), - content.to_vec() - ).await.map_err(FileServiceError::from)?; - - Ok(FileDto::from(file)) - } - - /// Updates an existing file (needed for WebDAV) - pub async fn update_file(&self, path: &str, content: &[u8]) -> FileServiceResult<()> { - // First, try to get the file by path - match self.get_file_by_path(path).await { - Ok(file) => { - // Update the file content - self.file_repository.update_file_content(&file.id, content.to_vec()) - .await - .map_err(FileServiceError::from) - }, - Err(_) => { - // If file doesn't exist, extract filename and parent path and create it - let path = path.trim_start_matches('/').trim_end_matches('/'); - let (parent_path, filename) = if let Some(idx) = path.rfind('/') { - (&path[..idx], &path[idx+1..]) - } else { - ("", path) - }; - - // Create new file - self.create_file(parent_path, filename, content, "application/octet-stream").await?; - - Ok(()) - } - } - } - - /// Lists files in a folder - pub async fn list_files(&self, folder_id: Option<&str>) -> FileServiceResult> { - let files = self.file_repository.list_files(folder_id).await - .map_err(FileServiceError::from)?; - Ok(files.into_iter().map(FileDto::from).collect()) - } - - /// Deletes a file - pub async fn delete_file(&self, id: &str) -> FileServiceResult<()> { - self.file_repository.delete_file(id).await - .map_err(FileServiceError::from) - } - - /// Gets file content as bytes - use for small files only - pub async fn get_file_content(&self, id: &str) -> FileServiceResult> { - self.file_repository.get_file_content(id).await - .map_err(FileServiceError::from) - } - - /// Gets file content as stream - better for large files - pub async fn get_file_stream(&self, id: &str) -> FileServiceResult> + Send>> { - self.file_repository.get_file_stream(id).await - .map_err(FileServiceError::from) - } - - /// Gets a range of file content as stream - for HTTP Range Requests - /// - /// Supports: - /// - Video seeking - /// - Resumable downloads - /// - Parallel chunk downloads - pub async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option - ) -> FileServiceResult> + Send>> { - self.file_repository.get_file_range_stream(id, start, end).await - .map_err(FileServiceError::from) - } - - /// Memory-maps a file for zero-copy access (optimal for 10-100MB files) - /// - /// Uses kernel mmap for files where: - /// - Full RAM cache would be wasteful - /// - Streaming adds unnecessary chunking overhead - pub async fn get_file_mmap(&self, id: &str) -> FileServiceResult { - self.file_repository.get_file_mmap(id).await - .map_err(FileServiceError::from) - } - - /// Moves a file to a new folder using filesystem operations directly - pub async fn move_file(&self, file_id: &str, folder_id: Option) -> FileServiceResult { - tracing::info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id); - - // Use the efficient repository implementation that uses rename - let moved_file = self.file_repository.move_file(file_id, folder_id).await - .map_err(|e| { - tracing::error!("Error moving file (ID: {}): {}", file_id, e); - FileServiceError::from(e) - })?; - - tracing::info!("File moved successfully: {} (ID: {}) to folder: {:?}", - moved_file.name(), moved_file.id(), moved_file.folder_id()); - - Ok(FileDto::from(moved_file)) - } -} - -#[async_trait] -impl FileUseCase for FileService { - async fn upload_file( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> Result { - FileService::upload_file_from_bytes(self, name, folder_id, content_type, content).await - .map_err(DomainError::from) - } - - async fn get_file(&self, id: &str) -> Result { - FileService::get_file(self, id).await - .map_err(DomainError::from) - } - - async fn get_file_by_path(&self, path: &str) -> Result { - FileService::get_file_by_path(self, path).await - .map_err(DomainError::from) - } - - async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result { - FileService::create_file(self, parent_path, filename, content, content_type).await - .map_err(DomainError::from) - } - - async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> { - FileService::update_file(self, path, content).await - .map_err(DomainError::from) - } - - async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { - FileService::list_files(self, folder_id).await - .map_err(DomainError::from) - } - - async fn delete_file(&self, id: &str) -> Result<(), DomainError> { - FileService::delete_file(self, id).await - .map_err(DomainError::from) - } - - async fn get_file_content(&self, id: &str) -> Result, DomainError> { - FileService::get_file_content(self, id).await - .map_err(DomainError::from) - } - - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError> { - FileService::get_file_stream(self, id).await - .map_err(DomainError::from) - } - - async fn move_file(&self, file_id: &str, folder_id: Option) -> Result { - FileService::move_file(self, file_id, folder_id).await - .map_err(DomainError::from) - } -} \ No newline at end of file diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index ea15bd23..329d8d50 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -1,57 +1,136 @@ use std::sync::Arc; +use std::pin::Pin; use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::file_ports::FileUploadUseCase; -use crate::application::ports::storage_ports::FileWritePort; +use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy}; +use crate::application::ports::storage_ports::{FileWritePort, FileReadPort}; +use crate::application::ports::cache_ports::WriteBehindCachePort; +use crate::application::ports::dedup_ports::DedupPort; use crate::common::errors::DomainError; -use crate::application::ports::storage_ports::StorageUsagePort; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; + +/// Threshold for using streaming upload (files >= 1MB use streaming) +const STREAMING_UPLOAD_THRESHOLD: usize = 1 * 1024 * 1024; +/// Threshold for write-behind cache (files < 256KB get instant response) +const WRITE_BEHIND_THRESHOLD: usize = 256 * 1024; /// Helper function to extract username from folder path string fn extract_username_from_path(path: &str) -> Option { - // Check if path contains the folder pattern if !path.contains("Mi Carpeta - ") { return None; } - - // Split by the pattern and get the second part let parts: Vec<&str> = path.split("Mi Carpeta - ").collect(); if parts.len() <= 1 { return None; } - - // Trim and return as owned String Some(parts[1].trim().to_string()) } /// Servicio para operaciones de subida de archivos +/// +/// Encapsulates the three-tier upload strategy: +/// 1. **Write-Behind** (<256 KB): store in RAM, respond instantly, flush async. +/// 2. **Buffered** (256 KB – 1 MB): collect bytes, write, respond. +/// 3. **Streaming** (≥1 MB): pipe chunks to disk with constant memory. +/// +/// Also runs deduplication so duplicate content is never stored twice. pub struct FileUploadService { - file_repository: Arc, - storage_usage_service: Option>, + /// Write port — handles save, streaming, deferred registration + file_write: Arc, + /// Read port — needed for WebDAV create_file / update_file + file_read: Option>, + /// Optional write-behind cache for instant uploads + write_behind: Option>, + /// Optional dedup service for content-addressable storage + dedup: Option>, + /// Optional storage usage tracking + storage_usage_service: Option>, } impl FileUploadService { - /// Crea un nuevo servicio de subida de archivos + /// Backward-compatible constructor (no write-behind, no dedup). pub fn new(file_repository: Arc) -> Self { - Self { - file_repository, + Self { + file_write: file_repository, + file_read: None, + write_behind: None, + dedup: None, storage_usage_service: None, } } - + + /// Full constructor with all infrastructure ports. + pub fn new_full( + file_write: Arc, + file_read: Arc, + write_behind: Arc, + dedup: Arc, + ) -> Self { + Self { + file_write, + file_read: Some(file_read), + write_behind: Some(write_behind), + dedup: Some(dedup), + storage_usage_service: None, + } + } + /// Configura el servicio de uso de almacenamiento pub fn with_storage_usage_service( - mut self, - storage_usage_service: Arc + mut self, + storage_usage_service: Arc, ) -> Self { self.storage_usage_service = Some(storage_usage_service); self } + + // ── private helpers ────────────────────────────────────────── + + /// Run dedup tracking (non-fatal on failure). + async fn run_dedup(&self, data: &[u8], content_type: &str) { + let Some(dedup) = &self.dedup else { return }; + match dedup.store_bytes(data, Some(content_type.to_string())).await { + Ok(result) => { + if result.was_deduplicated() { + info!( + "🔗 DEDUP: content already exists (hash: {}, saved {} bytes)", + &result.hash()[..12], + result.size() + ); + } else { + info!("💾 DEDUP: new content stored (hash: {})", &result.hash()[..12]); + } + } + Err(e) => { + warn!("⚠️ DEDUP: Failed to store in blob store: {}", e); + } + } + } + + /// Optionally update storage usage after a successful upload. + fn maybe_update_storage_usage(&self, file: &FileDto) { + if let Some(storage_service) = &self.storage_usage_service { + // Extract username from the file's own path (contains folder structure) + let file_path = file.path.clone(); + if let Some(username) = extract_username_from_path(&file_path) { + let service_clone = Arc::clone(storage_service); + tokio::spawn(async move { + match service_clone.update_user_storage_usage(&username).await { + Ok(usage) => debug!("Updated storage usage for user {} to {} bytes", username, usage), + Err(e) => warn!("Failed to update storage usage for {}: {}", username, e), + } + }); + } + } + } } #[async_trait] impl FileUploadUseCase for FileUploadService { + /// Simple byte-based upload (backward compatible). async fn upload_file( &self, name: String, @@ -59,43 +138,178 @@ impl FileUploadUseCase for FileUploadService { content_type: String, content: Vec, ) -> Result { - // Upload the file - let file = self.file_repository.save_file(name, folder_id, content_type, content).await?; - - // Extract the owner's user ID if available - // We could make this more explicit by adding a user_id parameter - if let Some(storage_service) = &self.storage_usage_service { - // Extract user ID from folder pattern 'Mi Carpeta - {username}' - if let Some(folder_id) = file.folder_id() { - // Since we don't have direct access to folder details, - // we'll use pattern matching on the folder ID - // In a more complete implementation, we would use a folder repository - let folder_id_str = folder_id; - - // Check if we can extract a username from context - if let Ok(folder_path) = self.file_repository.get_folder_path_str(folder_id_str).await { - // Process the string to extract username without creating borrowing issues - if let Some(username) = extract_username_from_path(&folder_path) { - // Find user by username and update their storage usage - // We do this asynchronously to avoid blocking the upload response - let service_clone = Arc::clone(storage_service); - tokio::spawn(async move { - match service_clone.update_user_storage_usage(&username).await { - Ok(usage) => { - debug!("Updated storage usage for user {} to {} bytes", username, usage); - }, - Err(e) => { - warn!("Failed to update storage usage for {}: {}", username, e); - } - } - }); + let file = self.file_write.save_file(name, folder_id, content_type, content).await?; + let dto = FileDto::from(file); + self.maybe_update_storage_usage(&dto); + Ok(dto) + } + + /// Smart three-tier upload with write-behind cache and dedup. + async fn smart_upload( + &self, + name: String, + folder_id: Option, + content_type: String, + chunks: Vec, + total_size: usize, + ) -> Result<(FileDto, UploadStrategy), DomainError> { + use futures::stream; + + // ─── Dedup (runs for all tiers) ────────────────────── + { + let dedup_data: Vec = { + let mut combined = Vec::with_capacity(total_size); + for chunk in &chunks { + combined.extend_from_slice(chunk); + } + combined + }; + self.run_dedup(&dedup_data, &content_type).await; + } + + // ─── TIER 1: Write-Behind (<256 KB) ────────────────── + if total_size < WRITE_BEHIND_THRESHOLD { + if let Some(wb) = &self.write_behind { + if wb.is_eligible_size(total_size) { + let data: Bytes = if chunks.len() == 1 { + chunks.into_iter().next().unwrap() + } else { + let mut combined = Vec::with_capacity(total_size); + for chunk in chunks { + combined.extend_from_slice(&chunk); + } + combined.into() + }; + + let (file, target_path) = self + .file_write + .register_file_deferred(name.clone(), folder_id, content_type, total_size as u64) + .await?; + let dto = FileDto::from(file); + + if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await { + return Err(DomainError::internal_error("file", format!( + "Write-behind cache failed: {}", + e + ))); } - } else { - warn!("Could not get folder path for ID: {}", folder_id_str); + + info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", name, dto.id); + self.maybe_update_storage_usage(&dto); + return Ok((dto, UploadStrategy::WriteBehind)); } } } - - Ok(FileDto::from(file)) + + // ─── TIER 2: Streaming (≥1 MB) ────────────────────── + if total_size >= STREAMING_UPLOAD_THRESHOLD { + let chunk_stream = stream::iter(chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c))); + let pinned_stream: Pin> + Send>> = + Box::pin(chunk_stream); + + let file = self + .file_write + .save_file_from_stream(name.clone(), folder_id, content_type, pinned_stream) + .await?; + let dto = FileDto::from(file); + info!( + "✅ STREAMING UPLOAD: {} ({} MB, ID: {})", + name, + total_size / (1024 * 1024), + dto.id + ); + self.maybe_update_storage_usage(&dto); + return Ok((dto, UploadStrategy::Streaming)); + } + + // ─── TIER 3: Buffered (256 KB – 1 MB) ─────────────── + let data = if chunks.len() == 1 { + chunks.into_iter().next().unwrap().to_vec() + } else { + let mut combined = Vec::with_capacity(total_size); + for chunk in chunks { + combined.extend_from_slice(&chunk); + } + combined + }; + + let file = self + .file_write + .save_file(name.clone(), folder_id, content_type, data) + .await?; + let dto = FileDto::from(file); + info!("✅ BUFFERED UPLOAD: {} (ID: {})", name, dto.id); + self.maybe_update_storage_usage(&dto); + Ok((dto, UploadStrategy::Buffered)) + } + + /// Creates a file at a specific path (for WebDAV PUT on new resource). + async fn create_file( + &self, + parent_path: &str, + filename: &str, + content: &[u8], + content_type: &str, + ) -> Result { + // Resolve parent folder ID from path + let parent_id = if !parent_path.is_empty() { + if let Some(file_read) = &self.file_read { + match file_read.get_parent_folder_id(parent_path).await { + Ok(id) => Some(id), + Err(_) => None, // If parent doesn't exist, use root + } + } else { + None + } + } else { + None + }; + + let file = self + .file_write + .save_file( + filename.to_string(), + parent_id, + content_type.to_string(), + content.to_vec(), + ) + .await?; + let dto = FileDto::from(file); + self.maybe_update_storage_usage(&dto); + Ok(dto) + } + + /// Updates an existing file's content, or creates it if not found (for WebDAV PUT). + async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> { + let path_normalized = path.trim_start_matches('/').trim_end_matches('/'); + + // Try to find the existing file by path + if let Some(file_read) = &self.file_read { + let all_files = file_read.list_files(None).await?; + for file in &all_files { + let dto = FileDto::from(file.clone()); + let dto_path = dto.path.trim_start_matches('/').trim_end_matches('/'); + if dto_path == path_normalized + || dto_path.ends_with(&format!("/{}", path_normalized)) + || path_normalized.ends_with(&format!("/{}", dto_path)) + { + // Found it — update in place + self.file_write + .update_file_content(file.id(), content.to_vec()) + .await?; + return Ok(()); + } + } + } + + // File not found — create it + let (parent_path, filename) = if let Some(idx) = path_normalized.rfind('/') { + (&path_normalized[..idx], &path_normalized[idx + 1..]) + } else { + ("", path_normalized) + }; + self.create_file(parent_path, filename, content, "application/octet-stream") + .await?; + Ok(()) } } \ No newline at end of file diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index e8826884..ab05c9b4 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -5,7 +5,6 @@ pub mod contact_service; pub mod favorites_service; pub mod file_management_service; pub mod file_retrieval_service; -pub mod file_service; pub mod file_upload_service; pub mod file_use_case_factory; pub mod folder_service; diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index dda05850..5fe8d916 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -1,24 +1,25 @@ use std::sync::Arc; use async_trait::async_trait; -use sqlx::{PgPool, Row}; -use tracing::{info, error}; -use uuid::Uuid; +use tracing::info; use crate::common::errors::{Result, DomainError, ErrorKind}; -use crate::application::ports::recent_ports::RecentItemsUseCase; +use crate::application::ports::recent_ports::{RecentItemsUseCase, RecentItemsRepositoryPort}; use crate::application::dtos::recent_dto::RecentItemDto; -/// Implementación del caso de uso para gestionar elementos recientes +/// Implementación del caso de uso para gestionar elementos recientes. +/// +/// Depende de `RecentItemsRepositoryPort` (outbound port) en lugar +/// de acceder directamente a `PgPool`, siguiendo la arquitectura hexagonal. pub struct RecentService { - db_pool: Arc, - max_recent_items: i32, // Número máximo de elementos recientes a mantener por usuario + repo: Arc, + max_recent_items: i32, } impl RecentService { /// Crear un nuevo servicio de elementos recientes - pub fn new(db_pool: Arc, max_recent_items: i32) -> Self { - Self { - db_pool, - max_recent_items: max_recent_items.max(1).min(100), // Entre 1 y 100 + pub fn new(repo: Arc, max_recent_items: i32) -> Self { + Self { + repo, + max_recent_items: max_recent_items.max(1).min(100), } } } @@ -28,205 +29,48 @@ impl RecentItemsUseCase for RecentService { /// Obtener elementos recientes de un usuario async fn get_recent_items(&self, user_id: &str, limit: Option) -> Result> { info!("Obteniendo elementos recientes para usuario: {}", user_id); - - // Convertir user_id a UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Determinar límite (usar el especificado o el máximo del servicio) let limit_value = limit.unwrap_or(self.max_recent_items).min(self.max_recent_items); - - // Ejecutar consulta SQL - let rows = sqlx::query( - r#" - SELECT - id::TEXT as "id", - user_id::TEXT as "user_id", - item_id as "item_id", - item_type as "item_type", - accessed_at as "accessed_at" - FROM auth.user_recent_files - WHERE user_id = $1::TEXT - ORDER BY accessed_at DESC - LIMIT $2 - "# - ) - .bind(user_uuid) - .bind(limit_value) - .fetch_all(&*self.db_pool) - .await - .map_err(|e| { - error!("Error de base de datos al obtener elementos recientes: {}", e); - DomainError::new( - ErrorKind::InternalError, - "RecentItems", - format!("Fallo al obtener elementos recientes: {}", e) - ) - })?; - - // Convertir filas a DTOs - let mut recent_items = Vec::with_capacity(rows.len()); - for row in rows { - recent_items.push(RecentItemDto { - id: row.get("id"), - user_id: row.get("user_id"), - item_id: row.get("item_id"), - item_type: row.get("item_type"), - accessed_at: row.get("accessed_at"), - }); - } - - info!("Recuperados {} elementos recientes para usuario {}", recent_items.len(), user_id); - Ok(recent_items) + let items = self.repo.get_recent_items(user_id, limit_value).await?; + info!("Recuperados {} elementos recientes para usuario {}", items.len(), user_id); + Ok(items) } - + /// Registrar acceso a un elemento async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { info!("Registrando acceso a {} '{}' para usuario {}", item_type, item_id, user_id); - - // Validar tipo de elemento + if item_type != "file" && item_type != "folder" { return Err(DomainError::new( ErrorKind::InvalidInput, "RecentItems", - "El tipo de elemento debe ser 'file' o 'folder'" + "El tipo de elemento debe ser 'file' o 'folder'", )); } - - // Convertir user_id a UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Ejecutar consulta SQL con UPSERT para mantener un único registro por elemento - sqlx::query( - r#" - INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at) - VALUES ($1::TEXT, $2, $3, CURRENT_TIMESTAMP) - ON CONFLICT (user_id, item_id, item_type) - DO UPDATE SET accessed_at = CURRENT_TIMESTAMP - "# - ) - .bind(user_uuid) - .bind(item_id) - .bind(item_type) - .execute(&*self.db_pool) - .await - .map_err(|e| { - error!("Error de base de datos al registrar acceso a elemento: {}", e); - DomainError::new( - ErrorKind::InternalError, - "RecentItems", - format!("Fallo al registrar acceso a elemento: {}", e) - ) - })?; - - // Eliminar elementos antiguos que excedan el límite - self.prune_old_items(user_id).await?; - + + self.repo.upsert_access(user_id, item_id, item_type).await?; + self.repo.prune(user_id, self.max_recent_items).await?; + info!("Registrado correctamente acceso a {} '{}' para usuario {}", item_type, item_id, user_id); Ok(()) } - + /// Eliminar un elemento de recientes async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { info!("Eliminando {} '{}' de recientes para usuario {}", item_type, item_id, user_id); - - // Convertir user_id a UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Ejecutar consulta SQL - let result = sqlx::query( - r#" - DELETE FROM auth.user_recent_files - WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3 - "# - ) - .bind(user_uuid) - .bind(item_id) - .bind(item_type) - .execute(&*self.db_pool) - .await - .map_err(|e| { - error!("Error de base de datos al eliminar elemento de recientes: {}", e); - DomainError::new( - ErrorKind::InternalError, - "RecentItems", - format!("Fallo al eliminar de recientes: {}", e) - ) - })?; - - let removed = result.rows_affected() > 0; + let removed = self.repo.remove_item(user_id, item_id, item_type).await?; info!( - "{} {} '{}' de recientes para usuario {}", + "{} {} '{}' de recientes para usuario {}", if removed { "Eliminado correctamente" } else { "No se encontró" }, - item_type, - item_id, - user_id + item_type, item_id, user_id ); - Ok(removed) } - + /// Limpiar todos los elementos recientes async fn clear_recent_items(&self, user_id: &str) -> Result<()> { info!("Limpiando todos los elementos recientes para usuario {}", user_id); - - // Convertir user_id a UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Ejecutar consulta SQL - sqlx::query( - r#" - DELETE FROM auth.user_recent_files - WHERE user_id = $1::TEXT - "# - ) - .bind(user_uuid) - .execute(&*self.db_pool) - .await - .map_err(|e| { - error!("Error de base de datos al limpiar elementos recientes: {}", e); - DomainError::new( - ErrorKind::InternalError, - "RecentItems", - format!("Fallo al limpiar elementos recientes: {}", e) - ) - })?; - + self.repo.clear_all(user_id).await?; info!("Limpiados todos los elementos recientes para usuario {}", user_id); - Ok(()) - } -} - -impl RecentService { - /// Método auxiliar para eliminar elementos antiguos que excedan el límite - async fn prune_old_items(&self, user_id: &str) -> Result<()> { - // Convertir user_id a UUID - let user_uuid = Uuid::parse_str(user_id)?; - - // Eliminar elementos antiguos que excedan el límite - sqlx::query( - r#" - DELETE FROM auth.user_recent_files - WHERE id IN ( - SELECT id FROM auth.user_recent_files - WHERE user_id = $1::TEXT - ORDER BY accessed_at DESC - OFFSET $2 - ) - "# - ) - .bind(user_uuid) - .bind(self.max_recent_items) - .execute(&*self.db_pool) - .await - .map_err(|e| { - error!("Error al podar elementos recientes antiguos: {}", e); - DomainError::new( - ErrorKind::InternalError, - "RecentItems", - format!("Fallo al limpiar elementos recientes antiguos: {}", e) - ) - })?; - Ok(()) } } \ No newline at end of file diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index ac803f65..f0e01e66 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -10,7 +10,8 @@ use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::ports::inbound::SearchUseCase; -use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; +use crate::application::ports::outbound::FolderStoragePort; +use crate::application::ports::storage_ports::FileReadPort; /** * Implementación del servicio de búsqueda para archivos y carpetas. @@ -22,7 +23,7 @@ use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; */ pub struct SearchService { /// Repositorio para operaciones con archivos - file_repository: Arc, + file_repository: Arc, /// Repositorio para operaciones con carpetas folder_repository: Arc, @@ -66,7 +67,7 @@ impl SearchService { * @param max_cache_size Tamaño máximo de la caché */ pub fn new( - file_repository: Arc, + file_repository: Arc, folder_repository: Arc, cache_ttl: u64, max_cache_size: usize, diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 78182792..4fd048cf 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -10,8 +10,10 @@ use crate::{ share_dto::{CreateShareDto, ShareDto, UpdateShareDto}, }, ports::{ - outbound::{FileStoragePort, FolderStoragePort}, + auth_ports::PasswordHasherPort, + outbound::FolderStoragePort, share_ports::{ShareStoragePort, ShareUseCase}, + storage_ports::FileReadPort, }, }, common::{config::AppConfig, errors::DomainError}, @@ -56,22 +58,25 @@ impl From for DomainError { pub struct ShareService { config: Arc, share_repository: Arc, - file_repository: Arc, + file_repository: Arc, folder_repository: Arc, + password_hasher: Arc, } impl ShareService { pub fn new( config: Arc, share_repository: Arc, - file_repository: Arc, + file_repository: Arc, folder_repository: Arc, + password_hasher: Arc, ) -> Self { Self { config, share_repository, file_repository, folder_repository, + password_hasher, } } @@ -98,11 +103,19 @@ impl ShareService { Ok(()) } - /// Hash de contraseña + /// Hash de contraseña usando Argon2id (resistente a timing attacks y GPU attacks) fn hash_password(&self, password: &str) -> String { - // En una implementación real, usar un algoritmo seguro como bcrypt - // Para simplificar, solo devolvemos la misma contraseña - password.to_string() + use argon2::{Argon2, PasswordHasher}; + use argon2::password_hash::SaltString; + use rand_core::OsRng; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + argon2 + .hash_password(password.as_bytes(), &salt) + .expect("Failed to hash share password") + .to_string() } } @@ -314,8 +327,13 @@ impl ShareUseCase for ShareService { return Err(ShareServiceError::Expired.into()); } - // Verificar la contraseña - Ok(share.verify_password(password)) + // Verificar la contraseña usando el port de infraestructura + match share.password_hash() { + Some(hash) => { + self.password_hasher.verify_password(password, hash) + } + None => Ok(true), // No password required + } } async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> { @@ -348,87 +366,147 @@ impl ShareUseCase for ShareService { mod tests { use super::*; use crate::application::ports::share_ports::ShareStoragePort; + use crate::application::ports::auth_ports::PasswordHasherPort; + use crate::application::dtos::share_dto::SharePermissionsDto; + use crate::common::config::AppConfig; + use crate::domain::repositories::folder_repository::FolderRepository; use async_trait::async_trait; use std::collections::HashMap; use std::sync::Mutex; + struct MockPasswordHasher; + + impl PasswordHasherPort for MockPasswordHasher { + fn hash_password(&self, password: &str) -> Result { + Ok(format!("hashed_{}", password)) + } + + fn verify_password(&self, _password: &str, _hash: &str) -> Result { + Ok(true) + } + } + struct MockFileRepository; struct MockFolderRepository; #[async_trait] - impl FileStoragePort for MockFileRepository { - async fn find_file_by_id(&self, id: &str) -> Result { + impl FileReadPort for MockFileRepository { + async fn get_file(&self, id: &str) -> Result { if id == "test_file_id" { let file = crate::domain::entities::file::File::new( id.to_string(), "test.txt".to_string(), - "/path/to/test.txt".to_string(), - "/test.txt".to_string(), + crate::domain::services::path_service::StoragePath::from_string("/path/to/test.txt"), 123, "text/plain".to_string(), None, - None, - None, ) .unwrap(); Ok(file) } else { - Err(DomainError::NotFound(format!("File {} not found", id))) + Err(DomainError::not_found("File", id)) } } - // Implementación dummy para el resto de métodos requeridos - async fn find_files_in_folder(&self, _folder_id: &str) -> Result, DomainError> { + async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { unimplemented!() } - async fn save_file(&self, _file: &crate::domain::entities::file::File) -> Result { + async fn get_file_content(&self, _id: &str) -> Result, DomainError> { unimplemented!() } - - async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { + + async fn get_file_stream( + &self, + _id: &str, + ) -> Result> + Send>, DomainError> { unimplemented!() } - - async fn find_all_files(&self) -> Result, DomainError> { + + async fn get_file_range_stream( + &self, + _id: &str, + _start: u64, + _end: Option, + ) -> Result> + Send>, DomainError> { + unimplemented!() + } + + async fn get_file_mmap(&self, _id: &str) -> Result { + unimplemented!() + } + + async fn get_file_path(&self, _id: &str) -> Result { + unimplemented!() + } + + async fn get_parent_folder_id(&self, _path: &str) -> Result { unimplemented!() } } #[async_trait] - impl FolderStoragePort for MockFolderRepository { - async fn find_folder_by_id(&self, id: &str) -> Result { + impl FolderRepository for MockFolderRepository { + async fn create_folder(&self, _name: String, _parent_id: Option) -> Result { + unimplemented!() + } + + async fn get_folder(&self, id: &str) -> Result { if id == "test_folder_id" { let folder = crate::domain::entities::folder::Folder::new( id.to_string(), "test".to_string(), - "/path/to/test".to_string(), - "/test".to_string(), - None, - None, + crate::domain::services::path_service::StoragePath::from_string("/path/to/test"), None, ) .unwrap(); Ok(folder) } else { - Err(DomainError::NotFound(format!("Folder {} not found", id))) + Err(DomainError::not_found("Folder", id)) } } - - // Implementación dummy para el resto de métodos requeridos - async fn find_folders_in_folder(&self, _folder_id: &str) -> Result, DomainError> { + + async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { unimplemented!() } - async fn save_folder(&self, _folder: &crate::domain::entities::folder::Folder) -> Result { + async fn list_folders(&self, _parent_id: Option<&str>) -> Result, DomainError> { + unimplemented!() + } + + async fn list_folders_paginated(&self, _parent_id: Option<&str>, _offset: usize, _limit: usize, _include_total: bool) -> Result<(Vec, Option), DomainError> { + unimplemented!() + } + + async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { + unimplemented!() + } + + async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result { unimplemented!() } async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { unimplemented!() } - - async fn find_all_folders(&self) -> Result, DomainError> { + + async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { + unimplemented!() + } + + async fn get_folder_path(&self, _id: &str) -> Result { + unimplemented!() + } + + async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> { + unimplemented!() + } + + async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> { + unimplemented!() + } + + async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> { unimplemented!() } } @@ -453,8 +531,8 @@ mod tests { let mut shares = self.shares.lock().unwrap(); let mut tokens = self.tokens.lock().unwrap(); - shares.insert(share.id.clone(), share.clone()); - tokens.insert(share.token.clone(), share.id.clone()); + shares.insert(share.id().to_string(), share.clone()); + tokens.insert(share.token().to_string(), share.id().to_string()); Ok(share.clone()) } @@ -464,7 +542,7 @@ mod tests { shares.get(id) .cloned() - .ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id))) + .ok_or_else(|| DomainError::not_found("Share", id)) } async fn find_share_by_token(&self, token: &str) -> Result { @@ -472,11 +550,11 @@ mod tests { let shares = self.shares.lock().unwrap(); let id = tokens.get(token) - .ok_or_else(|| DomainError::NotFound(format!("Share with token {} not found", token)))?; + .ok_or_else(|| DomainError::not_found("Share", token))?; shares.get(id) .cloned() - .ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id))) + .ok_or_else(|| DomainError::not_found("Share", id.as_str())) } async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result, DomainError> { @@ -484,7 +562,7 @@ mod tests { let type_str = item_type.to_string(); let result: Vec = shares.values() - .filter(|s| s.item_id == item_id && s.item_type.to_string() == type_str) + .filter(|s| s.item_id() == item_id && s.item_type().to_string() == type_str) .cloned() .collect(); @@ -494,11 +572,12 @@ mod tests { async fn update_share(&self, share: &Share) -> Result { let mut shares = self.shares.lock().unwrap(); - if !shares.contains_key(&share.id) { - return Err(DomainError::not_found("Share", &share.id)); + let id_str = share.id().to_string(); + if !shares.contains_key(&id_str) { + return Err(DomainError::not_found("Share", &id_str)); } - shares.insert(share.id.clone(), share.clone()); + shares.insert(id_str, share.clone()); Ok(share.clone()) } @@ -512,7 +591,7 @@ mod tests { .ok_or_else(|| DomainError::not_found("Share", id))?; // Remove token mapping - tokens.remove(&share.token); + tokens.remove(share.token()); // Remove the share shares.remove(id); @@ -524,7 +603,7 @@ mod tests { let shares = self.shares.lock().unwrap(); let user_shares: Vec = shares.values() - .filter(|s| s.created_by == user_id) + .filter(|s| s.created_by() == user_id) .cloned() .collect(); @@ -542,23 +621,14 @@ mod tests { #[tokio::test] async fn test_create_shared_link() { - let config = Arc::new(Config { - base_url: "http://localhost:8085".to_string(), - storage_path: "/tmp/storage".to_string(), - log_level: "info".to_string(), - port: 8085, - database_url: "".to_string(), - jwt_secret: "test_secret".to_string(), - jwt_expiration: 3600, - enable_cors: false, - cors_origins: vec![], - }); + let config = Arc::new(AppConfig::default()); let share_repo = Arc::new(MockShareRepository::new()); let file_repo = Arc::new(MockFileRepository); let folder_repo = Arc::new(MockFolderRepository); + let password_hasher = Arc::new(MockPasswordHasher); - let service = ShareService::new(config, share_repo, file_repo, folder_repo); + let service = ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher); // Test creating a file share let dto = CreateShareDto { @@ -580,6 +650,6 @@ mod tests { assert_eq!(share_dto.item_id, "test_file_id"); assert_eq!(share_dto.item_type, "file"); assert!(share_dto.has_password); - assert!(share_dto.url.starts_with("http://localhost:8085/s/")); + assert!(share_dto.url.starts_with("http://127.0.0.1:8085/s/")); } } \ No newline at end of file diff --git a/src/application/services/storage_mediator.rs b/src/application/services/storage_mediator.rs index 3df8e135..7dfb31c0 100644 --- a/src/application/services/storage_mediator.rs +++ b/src/application/services/storage_mediator.rs @@ -1,14 +1,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::RwLock; use async_trait::async_trait; use thiserror::Error; use crate::domain::entities::folder::Folder; -use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryError}; -use crate::domain::repositories::file_repository::FileRepositoryError; use crate::domain::services::path_service::StoragePath; -use crate::application::ports::outbound::{IdMappingPort, StoragePort}; +use crate::application::ports::outbound::{IdMappingPort, StoragePort, FolderStoragePort}; /// Errores específicos del mediador de almacenamiento #[derive(Debug, Error)] @@ -32,30 +29,6 @@ pub enum StorageMediatorError { DomainError(#[from] crate::common::errors::DomainError), } -impl From for StorageMediatorError { - fn from(err: FolderRepositoryError) -> Self { - match err { - FolderRepositoryError::NotFound(id) => StorageMediatorError::NotFound(id), - FolderRepositoryError::AlreadyExists(path) => StorageMediatorError::AlreadyExists(path), - FolderRepositoryError::InvalidPath(path) => StorageMediatorError::InvalidPath(path), - FolderRepositoryError::IoError(e) => StorageMediatorError::AccessError(e.to_string()), - _ => StorageMediatorError::InternalError(err.to_string()), - } - } -} - -impl From for StorageMediatorError { - fn from(err: FileRepositoryError) -> Self { - match err { - FileRepositoryError::NotFound(id) => StorageMediatorError::NotFound(id), - FileRepositoryError::AlreadyExists(path) => StorageMediatorError::AlreadyExists(path), - FileRepositoryError::InvalidPath(path) => StorageMediatorError::InvalidPath(path), - FileRepositoryError::IoError(e) => StorageMediatorError::AccessError(e.to_string()), - _ => StorageMediatorError::InternalError(err.to_string()), - } - } -} - /// Tipo de resultado para las operaciones del mediador pub type StorageMediatorResult = Result; @@ -98,113 +71,20 @@ pub trait StorageMediator: Send + Sync + 'static { /// Implementación concreta del mediador de almacenamiento pub struct FileSystemStorageMediator { - pub folder_repository: Arc, + pub folder_storage_port: Arc, pub path_service: Arc, pub id_mapping: Arc, } impl FileSystemStorageMediator { - pub fn new(folder_repository: Arc, path_service: Arc, id_mapping: Arc) -> Self { - Self { folder_repository, path_service, id_mapping } + pub fn new(folder_storage_port: Arc, path_service: Arc, id_mapping: Arc) -> Self { + Self { folder_storage_port, path_service, id_mapping } } /// Creates a stub implementation for initialization bootstrapping pub fn new_stub() -> StubStorageMediator { StubStorageMediator::new() } - - /// Overload para implementar inicialización diferida con repository placeholder - pub fn new_with_lazy_folder( - _folder_repository: Arc>>>, - path_service: Arc, - id_mapping: Arc - ) -> Self { - // Create temporary stub repository - let temp_repo = Arc::new(FolderRepositoryStub {}); - - Self { - folder_repository: temp_repo, - path_service, - id_mapping, - } - } -} - -/// Stub repository for initialization -#[derive(Debug)] -pub struct FolderRepositoryStub {} - -#[async_trait] -impl FolderRepository for FolderRepositoryStub { - async fn create_folder(&self, _name: String, _parent_id: Option) -> Result { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn get_folder_by_id(&self, _id: &str) -> Result { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn get_folder_by_storage_path(&self, _storage_path: &StoragePath) -> Result { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn list_folders(&self, _parent_id: Option<&str>) -> Result, FolderRepositoryError> { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn list_folders_paginated( - &self, - _parent_id: Option<&str>, - _offset: usize, - _limit: usize, - _include_total: bool - ) -> Result<(Vec, Option), FolderRepositoryError> { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn delete_folder(&self, _id: &str) -> Result<(), FolderRepositoryError> { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - async fn folder_exists_at_storage_path(&self, _storage_path: &StoragePath) -> Result { - Ok(false) - } - - async fn get_folder_storage_path(&self, _id: &str) -> Result { - Ok(StoragePath::root()) - } - - // Legacy methods - #[allow(deprecated)] - async fn folder_exists(&self, _path: &std::path::PathBuf) -> Result { - Ok(false) - } - - #[allow(deprecated)] - async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> Result { - Err(FolderRepositoryError::Other("Stub repository".to_string())) - } - - // Trash functionality stubs - async fn move_to_trash(&self, _folder_id: &str) -> Result<(), FolderRepositoryError> { - Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string())) - } - - async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), FolderRepositoryError> { - Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string())) - } - - async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), FolderRepositoryError> { - Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string())) - } } /// Stub implementation for initialization dependency issues @@ -276,7 +156,7 @@ impl StorageMediator for StubStorageMediator { #[async_trait] impl StorageMediator for FileSystemStorageMediator { async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult { - let folder = self.folder_repository.get_folder_by_id(folder_id).await + let folder = self.folder_storage_port.get_folder(folder_id).await .map_err(StorageMediatorError::from)?; // Need to get the path from folder ID @@ -289,7 +169,7 @@ impl StorageMediator for FileSystemStorageMediator { } async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult { - let folder = self.folder_repository.get_folder_by_id(folder_id).await + let folder = self.folder_storage_port.get_folder(folder_id).await .map_err(StorageMediatorError::from)?; // Get path by folder ID - will already be a StoragePath @@ -300,7 +180,7 @@ impl StorageMediator for FileSystemStorageMediator { } async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult { - let folder = self.folder_repository.get_folder_by_id(folder_id).await + let folder = self.folder_storage_port.get_folder(folder_id).await .map_err(StorageMediatorError::from)?; Ok(folder) diff --git a/src/application/services/storage_usage_service.rs b/src/application/services/storage_usage_service.rs index 47472d03..3d6d188a 100644 --- a/src/application/services/storage_usage_service.rs +++ b/src/application/services/storage_usage_service.rs @@ -3,8 +3,7 @@ use async_trait::async_trait; use tokio::task; use crate::common::errors::DomainError; use crate::application::ports::auth_ports::UserStoragePort; -use crate::application::ports::outbound::FileStoragePort; -use crate::application::ports::storage_ports::StorageUsagePort; +use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use tracing::{info, error, debug}; /** @@ -14,14 +13,14 @@ use tracing::{info, error, debug}; * is using and updating this information in the user records. */ pub struct StorageUsageService { - file_repository: Arc, + file_repository: Arc, user_repository: Arc, } impl StorageUsageService { /// Creates a new storage usage service pub fn new( - file_repository: Arc, + file_repository: Arc, user_repository: Arc, ) -> Self { Self { @@ -90,7 +89,7 @@ impl StorageUsageService { async fn calculate_folder_size(&self, folder_id: &str) -> Result { // Implementation with explicit boxing to handle recursion in async functions async fn inner_calculate_size( - repo: Arc, + repo: Arc, folder_id: &str, ) -> Result { let mut total_size: i64 = 0; diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 7a424c36..33b8bb65 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -5,10 +5,10 @@ use tracing::{debug, error, info, instrument}; use crate::application::dtos::trash_dto::TrashedItemDto; use crate::application::ports::trash_ports::TrashUseCase; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::ports::outbound::FolderStoragePort; use crate::common::errors::{Result, DomainError, ErrorKind}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; -use crate::domain::repositories::file_repository::FileRepository; -use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::trash_repository::TrashRepository; /** @@ -20,7 +20,7 @@ use crate::domain::repositories::trash_repository::TrashRepository; * repositories while enforcing business rules like retention policies. * * This service follows the Clean Architecture pattern by: - * - Depending on domain interfaces rather than concrete implementations + * - Depending on application ports rather than domain/infrastructure traits * - Orchestrating domain operations without containing domain logic * - Exposing its functionality through the TrashUseCase port */ @@ -28,11 +28,14 @@ pub struct TrashService { /// Repository for trash-specific operations like listing and retrieving trashed items trash_repository: Arc, - /// Repository for file operations used when trashing, restoring, or deleting files - file_repository: Arc, + /// Port for file read operations (get file metadata) + file_read_port: Arc, - /// Repository for folder operations used when trashing, restoring, or deleting folders - folder_repository: Arc, + /// Port for file write operations (trash, restore, delete) + file_write_port: Arc, + + /// Port for folder operations (get folder, trash, restore, delete) + folder_storage_port: Arc, /// Number of days items should be kept in trash before automatic cleanup retention_days: u32, @@ -41,33 +44,35 @@ pub struct TrashService { impl TrashService { pub fn new( trash_repository: Arc, - file_repository: Arc, - folder_repository: Arc, + file_read_port: Arc, + file_write_port: Arc, + folder_storage_port: Arc, retention_days: u32, ) -> Self { Self { trash_repository, - file_repository, - folder_repository, + file_read_port, + file_write_port, + folder_storage_port, retention_days, } } /// Converts a TrashedItem entity to a DTO fn to_dto(&self, item: TrashedItem) -> TrashedItemDto { - // Calculate days_until_deletion before moving item.original_path + // Calculate days_until_deletion before moving item fields let days_until_deletion = item.days_until_deletion(); TrashedItemDto { - id: item.id.to_string(), - original_id: item.original_id.to_string(), - item_type: match item.item_type { + id: item.id().to_string(), + original_id: item.original_id().to_string(), + item_type: match item.item_type() { TrashedItemType::File => "file".to_string(), TrashedItemType::Folder => "folder".to_string(), }, - name: item.name, - original_path: item.original_path, - trashed_at: item.trashed_at, + name: item.name().to_string(), + original_path: item.original_path().to_string(), + trashed_at: item.trashed_at(), days_until_deletion, } } @@ -83,10 +88,10 @@ impl TrashService { match self.trash_repository.get_trash_item(&item_uuid, &user_uuid).await? { Some(item) => { - if item.user_id != user_uuid { + if item.user_id() != user_uuid { error!( "User {} attempted to access trash item {} owned by {}", - user_id, item_id, item.user_id + user_id, item_id, item.user_id() ); return Err(DomainError::access_denied( "TrashItem", @@ -130,10 +135,9 @@ impl TrashUseCase for TrashService { info!("Moving to trash: type={}, id={}, user={}", item_type, item_id, user_id); debug!("User UUID validation: {}", user_id); - // Validate user ownership - debug!("Validating user permissions"); - self.validate_user_ownership(item_id, user_id).await?; - debug!("User permissions validated"); + // Note: We do NOT call validate_user_ownership here because the item + // is not yet in the trash. Ownership validation is only for operations + // on already-trashed items (restore, delete_permanently). // Parse UUIDs with detailed error handling debug!("Validating item UUID: {}", item_id); @@ -166,7 +170,7 @@ impl TrashUseCase for TrashService { // Get the file to verify it exists and capture its data debug!("Getting file data: {}", item_id); - let file = match self.file_repository.get_file_by_id(item_id).await { + let file = match self.file_read_port.get_file(item_id).await { Ok(file) => { debug!("File found: {} ({})", file.name(), item_id); file @@ -194,7 +198,7 @@ impl TrashUseCase for TrashService { original_path, self.retention_days, ); - debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id); + debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id()); // First add to trash index to register the item info!("Adding file {} to trash index", item_id); @@ -210,7 +214,7 @@ impl TrashUseCase for TrashService { // Then physically move the file to trash info!("Physically moving file to trash: {}", item_id); - match self.file_repository.move_to_trash(item_id).await { + match self.file_write_port.move_to_trash(item_id).await { Ok(_) => { debug!("File physically moved to trash successfully: {}", item_id); }, @@ -229,7 +233,7 @@ impl TrashUseCase for TrashService { }, "folder" => { // Get the folder to verify it exists and capture its data - let folder = self.folder_repository.get_folder_by_id(item_id).await + let folder = self.folder_storage_port.get_folder(item_id).await .map_err(|e| DomainError::new( ErrorKind::NotFound, "Folder", @@ -259,7 +263,7 @@ impl TrashUseCase for TrashService { }; // Then physically move the folder to trash - self.folder_repository.move_to_trash(item_id).await + self.folder_storage_port.move_to_trash(item_id).await .map_err(|e| DomainError::new( ErrorKind::InternalError, "Folder", @@ -306,17 +310,17 @@ impl TrashUseCase for TrashService { match item_result { Ok(Some(item)) => { info!("Found item in trash: ID={}, Type={:?}, OriginalID={}", - trash_id, item.item_type, item.original_id); + trash_id, item.item_type(), item.original_id()); // Restore based on type - match item.item_type { + match item.item_type() { TrashedItemType::File => { // Restore the file to its original location - let file_id = item.original_id.to_string(); - let original_path = item.original_path.clone(); + let file_id = item.original_id().to_string(); + let original_path = item.original_path().to_string(); info!("Restoring file from trash: ID={}, OriginalPath={}", file_id, original_path); - match self.file_repository.restore_from_trash(&file_id, &original_path).await { + match self.file_write_port.restore_from_trash(&file_id, &original_path).await { Ok(_) => { info!("Successfully restored file from trash: {}", file_id); }, @@ -339,11 +343,11 @@ impl TrashUseCase for TrashService { }, TrashedItemType::Folder => { // Restore the folder to its original location - let folder_id = item.original_id.to_string(); - let original_path = item.original_path.clone(); + let folder_id = item.original_id().to_string(); + let original_path = item.original_path().to_string(); info!("Restoring folder from trash: ID={}, OriginalPath={}", folder_id, original_path); - match self.folder_repository.restore_from_trash(&folder_id, &original_path).await { + match self.folder_storage_port.restore_from_trash(&folder_id, &original_path).await { Ok(_) => { info!("Successfully restored folder from trash: {}", folder_id); }, @@ -431,16 +435,16 @@ impl TrashUseCase for TrashService { match item_result { Ok(Some(item)) => { info!("Found item in trash: ID={}, Type={:?}, OriginalID={}", - trash_id, item.item_type, item.original_id); + trash_id, item.item_type(), item.original_id()); // Permanently delete based on type - match item.item_type { + match item.item_type() { TrashedItemType::File => { // Eliminar el archivo permanentemente - let file_id = item.original_id.to_string(); + let file_id = item.original_id().to_string(); info!("Permanently deleting file: {}", file_id); - match self.file_repository.delete_file_permanently(&file_id).await { + match self.file_write_port.delete_file_permanently(&file_id).await { Ok(_) => { info!("Successfully deleted file permanently: {}", file_id); }, @@ -463,10 +467,10 @@ impl TrashUseCase for TrashService { }, TrashedItemType::Folder => { // Eliminar la carpeta permanentemente - let folder_id = item.original_id.to_string(); + let folder_id = item.original_id().to_string(); info!("Permanently deleting folder: {}", folder_id); - match self.folder_repository.delete_folder_permanently(&folder_id).await { + match self.folder_storage_port.delete_folder_permanently(&folder_id).await { Ok(_) => { info!("Successfully deleted folder permanently: {}", folder_id); }, @@ -532,18 +536,18 @@ impl TrashUseCase for TrashService { // Permanently delete each item for item in items { - match item.item_type { + match item.item_type() { TrashedItemType::File => { // Permanently delete the file - let file_id = item.original_id.to_string(); - if let Err(e) = self.file_repository.delete_file_permanently(&file_id).await { + let file_id = item.original_id().to_string(); + if let Err(e) = self.file_write_port.delete_file_permanently(&file_id).await { error!("Error permanently deleting file {}: {}", file_id, e); } }, TrashedItemType::Folder => { // Permanently delete the folder - let folder_id = item.original_id.to_string(); - if let Err(e) = self.folder_repository.delete_folder_permanently(&folder_id).await { + let folder_id = item.original_id().to_string(); + if let Err(e) = self.folder_storage_port.delete_folder_permanently(&folder_id).await { error!("Error permanently deleting folder {}: {}", folder_id, e); } } diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index b3d31386..86abdc85 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -1,17 +1,20 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use std::path::PathBuf; use chrono::Utc; use async_trait::async_trait; use uuid::Uuid; +use bytes::Bytes; +use futures::Stream; use crate::common::errors::{Result, DomainError}; use crate::domain::entities::file::File; use crate::domain::entities::folder::Folder; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; -use crate::domain::repositories::file_repository::{FileRepository, FileRepositoryResult, FileRepositoryError}; -use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryResult, FolderRepositoryError}; use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::services::path_service::StoragePath; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::domain::repositories::folder_repository::FolderRepository; use crate::application::services::trash_service::TrashService; // Mock repositories for testing @@ -31,14 +34,14 @@ impl MockTrashRepository { impl TrashRepository for MockTrashRepository { async fn add_to_trash(&self, item: &TrashedItem) -> Result<()> { let mut items = self.trash_items.lock().unwrap(); - items.insert(item.id, item.clone()); + items.insert(item.id(), item.clone()); Ok(()) } async fn get_trash_items(&self, user_id: &Uuid) -> Result> { let items = self.trash_items.lock().unwrap(); let user_items = items.values() - .filter(|item| item.user_id == *user_id) + .filter(|item| item.user_id() == *user_id) .cloned() .collect(); Ok(user_items) @@ -47,7 +50,7 @@ impl TrashRepository for MockTrashRepository { async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { let items = self.trash_items.lock().unwrap(); let item = items.get(id) - .filter(|item| item.user_id == *user_id) + .filter(|item| item.user_id() == *user_id) .cloned(); Ok(item) } @@ -55,7 +58,7 @@ impl TrashRepository for MockTrashRepository { async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> { let mut items = self.trash_items.lock().unwrap(); if let Some(item) = items.get(id) { - if item.user_id == *user_id { + if item.user_id() == *user_id { items.remove(id); } } @@ -65,7 +68,7 @@ impl TrashRepository for MockTrashRepository { async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()> { let mut items = self.trash_items.lock().unwrap(); if let Some(item) = items.get(id) { - if item.user_id == *user_id { + if item.user_id() == *user_id { items.remove(id); } } @@ -74,7 +77,7 @@ impl TrashRepository for MockTrashRepository { async fn clear_trash(&self, user_id: &Uuid) -> Result<()> { let mut items = self.trash_items.lock().unwrap(); - items.retain(|_, item| item.user_id != *user_id); + items.retain(|_, item| item.user_id() != *user_id); Ok(()) } @@ -82,7 +85,7 @@ impl TrashRepository for MockTrashRepository { let items = self.trash_items.lock().unwrap(); let now = Utc::now(); let expired = items.values() - .filter(|item| item.deletion_date <= now) + .filter(|item| item.deletion_date() <= now) .cloned() .collect(); Ok(expired) @@ -118,17 +121,102 @@ impl MockFileRepository { } #[async_trait] -impl FileRepository for MockFileRepository { - async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult { +impl FileReadPort for MockFileRepository { + async fn get_file(&self, id: &str) -> std::result::Result { let files = self.files.lock().unwrap(); if let Some(file) = files.get(id) { Ok(file.clone()) } else { - Err(FileRepositoryError::NotFound(id.to_string())) + Err(DomainError::not_found("File", id.to_string())) } } - async fn move_to_trash(&self, id: &str) -> FileRepositoryResult<()> { + async fn list_files(&self, _folder_id: Option<&str>) -> std::result::Result, DomainError> { + Ok(vec![]) + } + + async fn get_file_content(&self, _id: &str) -> std::result::Result, DomainError> { + Ok(vec![]) + } + + async fn get_file_stream( + &self, + _id: &str, + ) -> std::result::Result> + Send>, DomainError> { + unimplemented!() + } + + async fn get_file_range_stream( + &self, + _id: &str, + _start: u64, + _end: Option, + ) -> std::result::Result> + Send>, DomainError> { + unimplemented!() + } + + async fn get_file_mmap(&self, _id: &str) -> std::result::Result { + unimplemented!() + } + + async fn get_file_path(&self, _id: &str) -> std::result::Result { + unimplemented!() + } + + async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result { + unimplemented!() + } +} + +#[async_trait] +impl FileWritePort for MockFileRepository { + async fn save_file( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _content: Vec, + ) -> std::result::Result { + unimplemented!() + } + + async fn save_file_from_stream( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _stream: std::pin::Pin> + Send>>, + ) -> std::result::Result { + unimplemented!() + } + + async fn move_file( + &self, + _file_id: &str, + _target_folder_id: Option, + ) -> std::result::Result { + unimplemented!() + } + + async fn delete_file(&self, _id: &str) -> std::result::Result<(), DomainError> { + Ok(()) + } + + async fn update_file_content(&self, _file_id: &str, _content: Vec) -> std::result::Result<(), DomainError> { + Ok(()) + } + + async fn register_file_deferred( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _size: u64, + ) -> std::result::Result<(File, PathBuf), DomainError> { + unimplemented!() + } + + async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); @@ -136,11 +224,11 @@ impl FileRepository for MockFileRepository { trashed.insert(id.to_string(), file); Ok(()) } else { - Err(FileRepositoryError::NotFound(id.to_string())) + Err(DomainError::not_found("File", id.to_string())) } } - async fn restore_from_trash(&self, id: &str, original_path: &str) -> FileRepositoryResult<()> { + async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); @@ -148,26 +236,18 @@ impl FileRepository for MockFileRepository { files.insert(id.to_string(), file); Ok(()) } else { - Err(FileRepositoryError::NotFound(format!("File {} not found in trash", id))) + Err(DomainError::not_found("File", format!("File {} not found in trash", id))) } } - async fn delete_file_permanently(&self, id: &str) -> FileRepositoryResult<()> { + async fn delete_file_permanently(&self, id: &str) -> std::result::Result<(), DomainError> { let mut trashed = self.trashed_files.lock().unwrap(); if trashed.remove(id).is_some() { Ok(()) } else { - Err(FileRepositoryError::NotFound(format!("File {} not found in trash", id))) + Err(DomainError::not_found("File", format!("File {} not found in trash", id))) } } - - // Other methods required by the trait (not used in tests) - async fn save_file(&self, _file: &File) -> FileRepositoryResult<()> { Ok(()) } - async fn delete_file(&self, _id: &str) -> FileRepositoryResult<()> { Ok(()) } - async fn get_files_in_folder(&self, _folder_id: Option<&str>) -> FileRepositoryResult> { Ok(vec![]) } - async fn move_file(&self, _id: &str, _new_folder_id: Option<&str>) -> FileRepositoryResult<()> { Ok(()) } - async fn update_file_data(&self, _id: &str, _new_data: &[u8]) -> FileRepositoryResult<()> { Ok(()) } - async fn get_file_data(&self, _id: &str) -> FileRepositoryResult> { Ok(vec![]) } } struct MockFolderRepository { @@ -198,16 +278,58 @@ impl MockFolderRepository { #[async_trait] impl FolderRepository for MockFolderRepository { - async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult { + async fn create_folder(&self, _name: String, _parent_id: Option) -> std::result::Result { + unimplemented!() + } + + async fn get_folder(&self, id: &str) -> std::result::Result { let folders = self.folders.lock().unwrap(); if let Some(folder) = folders.get(id) { Ok(folder.clone()) } else { - Err(FolderRepositoryError::NotFound(id.to_string())) + Err(DomainError::not_found("Folder", id.to_string())) } } - async fn move_to_trash(&self, id: &str) -> FolderRepositoryResult<()> { + async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> std::result::Result { + unimplemented!() + } + + async fn list_folders(&self, _parent_id: Option<&str>) -> std::result::Result, DomainError> { + Ok(vec![]) + } + + async fn list_folders_paginated( + &self, + _parent_id: Option<&str>, + _offset: usize, + _limit: usize, + _include_total: bool, + ) -> std::result::Result<(Vec, Option), DomainError> { + Ok((vec![], Some(0))) + } + + async fn rename_folder(&self, _id: &str, _new_name: String) -> std::result::Result { + unimplemented!() + } + + async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> std::result::Result { + unimplemented!() + } + + async fn delete_folder(&self, _id: &str) -> std::result::Result<(), DomainError> { + Ok(()) + } + + async fn folder_exists(&self, _storage_path: &StoragePath) -> std::result::Result { + Ok(false) + } + + async fn get_folder_path(&self, _id: &str) -> std::result::Result { + Ok(StoragePath::from_string("/")) + } + + async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); @@ -215,11 +337,11 @@ impl FolderRepository for MockFolderRepository { trashed.insert(id.to_string(), folder); Ok(()) } else { - Err(FolderRepositoryError::NotFound(id.to_string())) + Err(DomainError::not_found("Folder", id.to_string())) } } - async fn restore_from_trash(&self, id: &str, original_path: &str) -> FolderRepositoryResult<()> { + async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); @@ -227,29 +349,24 @@ impl FolderRepository for MockFolderRepository { folders.insert(id.to_string(), folder); Ok(()) } else { - Err(FolderRepositoryError::NotFound(format!("Folder {} not found in trash", id))) + Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id))) } } - async fn delete_folder_permanently(&self, id: &str) -> FolderRepositoryResult<()> { + async fn delete_folder_permanently(&self, id: &str) -> std::result::Result<(), DomainError> { let mut trashed = self.trashed_folders.lock().unwrap(); if trashed.remove(id).is_some() { Ok(()) } else { - Err(FolderRepositoryError::NotFound(format!("Folder {} not found in trash", id))) + Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id))) } } - - // Other methods required by the trait (not used in tests) - async fn save_folder(&self, _folder: &Folder) -> FolderRepositoryResult<()> { Ok(()) } - async fn delete_folder(&self, _id: &str) -> FolderRepositoryResult<()> { Ok(()) } - async fn get_folders_in_folder(&self, _parent_id: Option<&str>) -> FolderRepositoryResult> { Ok(vec![]) } - async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> FolderRepositoryResult<()> { Ok(()) } } #[cfg(test)] mod tests { use super::*; + use crate::application::ports::trash_ports::TrashUseCase; #[tokio::test] async fn test_move_file_to_trash() { @@ -260,7 +377,8 @@ mod tests { let service = TrashService::new( trash_repo.clone(), - file_repo.clone(), + file_repo.clone() as Arc, + file_repo.clone() as Arc, folder_repo.clone(), 30, // 30 days retention ); @@ -284,10 +402,10 @@ mod tests { assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash"); let trash_item = &trash_items[0]; - assert_eq!(trash_item.original_id.to_string(), file_id, "Original ID should match file ID"); - assert_eq!(trash_item.user_id.to_string(), user_id, "User ID should match"); - assert_eq!(trash_item.item_type, TrashedItemType::File, "Item type should be File"); - assert_eq!(trash_item.name, "test.txt", "File name should match"); + assert_eq!(trash_item.original_id().to_string(), file_id, "Original ID should match file ID"); + assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match"); + assert_eq!(*trash_item.item_type(), TrashedItemType::File, "Item type should be File"); + assert_eq!(trash_item.name(), "test.txt", "File name should match"); // Verify file is moved in file repository let files = file_repo.files.lock().unwrap(); @@ -306,7 +424,8 @@ mod tests { let service = TrashService::new( trash_repo.clone(), - file_repo.clone(), + file_repo.clone() as Arc, + file_repo.clone() as Arc, folder_repo.clone(), 30, // 30 days retention ); @@ -330,10 +449,10 @@ mod tests { assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash"); let trash_item = &trash_items[0]; - assert_eq!(trash_item.original_id.to_string(), folder_id, "Original ID should match folder ID"); - assert_eq!(trash_item.user_id.to_string(), user_id, "User ID should match"); - assert_eq!(trash_item.item_type, TrashedItemType::Folder, "Item type should be Folder"); - assert_eq!(trash_item.name, "test_folder", "Folder name should match"); + assert_eq!(trash_item.original_id().to_string(), folder_id, "Original ID should match folder ID"); + assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match"); + assert_eq!(*trash_item.item_type(), TrashedItemType::Folder, "Item type should be Folder"); + assert_eq!(trash_item.name(), "test_folder", "Folder name should match"); } #[tokio::test] @@ -345,7 +464,8 @@ mod tests { let service = TrashService::new( trash_repo.clone(), - file_repo.clone(), + file_repo.clone() as Arc, + file_repo.clone() as Arc, folder_repo.clone(), 30, // 30 days retention ); @@ -361,7 +481,7 @@ mod tests { // Get the trash item ID let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); - let trash_id = trash_items[0].id.to_string(); + let trash_id = trash_items[0].id().to_string(); // Act let result = service.restore_item(&trash_id, user_id).await; @@ -390,7 +510,8 @@ mod tests { let service = TrashService::new( trash_repo.clone(), - file_repo.clone(), + file_repo.clone() as Arc, + file_repo.clone() as Arc, folder_repo.clone(), 30, // 30 days retention ); @@ -405,7 +526,7 @@ mod tests { // Get the trash item ID let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); - let trash_id = trash_items[0].id.to_string(); + let trash_id = trash_items[0].id().to_string(); // Act let result = service.delete_permanently(&trash_id, user_id).await; @@ -434,7 +555,8 @@ mod tests { let service = TrashService::new( trash_repo.clone(), - file_repo.clone(), + file_repo.clone() as Arc, + file_repo.clone() as Arc, folder_repo.clone(), 30, // 30 days retention ); diff --git a/src/common/adapters.rs b/src/common/adapters.rs deleted file mode 100644 index b028fa69..00000000 --- a/src/common/adapters.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Adaptadores para convertir entre interfaces de dominio y aplicación -//! -//! Este módulo contiene adaptadores que permiten usar repositorios que implementan -//! `FileStoragePort` y `FolderStoragePort` donde se espera `FileRepository` y `FolderRepository`. - -use std::sync::Arc; -use async_trait::async_trait; - -use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; -use crate::domain::entities::file::File; -use crate::domain::entities::folder::Folder; -use crate::domain::repositories::file_repository::{FileRepository, FileRepositoryResult, FileRepositoryError}; -use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryResult, FolderRepositoryError}; -use crate::domain::services::path_service::StoragePath; - -/// Adaptador que convierte FileStoragePort a FileRepository -pub struct DomainFileRepoAdapter { - repo: Arc, -} - -impl DomainFileRepoAdapter { - pub fn new(repo: Arc) -> Self { - Self { repo } - } -} - -#[async_trait] -impl FileRepository for DomainFileRepoAdapter { - async fn save_file_from_bytes( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> FileRepositoryResult { - self.repo.save_file(name, folder_id, content_type, content) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn save_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - stream: std::pin::Pin> + Send>>, - ) -> FileRepositoryResult { - self.repo.save_file_from_stream(name, folder_id, content_type, stream) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn save_file_with_id( - &self, - _id: String, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> FileRepositoryResult { - Err(FileRepositoryError::Other("Not implemented".to_string())) - } - - async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult { - self.repo.get_file(id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn list_files(&self, folder_id: Option<&str>) -> FileRepositoryResult> { - self.repo.list_files(folder_id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn delete_file(&self, id: &str) -> FileRepositoryResult<()> { - self.repo.delete_file(id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn delete_file_entry(&self, id: &str) -> FileRepositoryResult<()> { - self.delete_file(id).await - } - - async fn get_file_content(&self, id: &str) -> FileRepositoryResult> { - self.repo.get_file_content(id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_stream(&self, id: &str) -> FileRepositoryResult> + Send>> { - self.repo.get_file_stream(id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option - ) -> FileRepositoryResult> + Send>> { - self.repo.get_file_range_stream(id, start, end) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_mmap(&self, id: &str) -> FileRepositoryResult { - self.repo.get_file_mmap(id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn move_file(&self, id: &str, target_folder_id: Option) -> FileRepositoryResult { - self.repo.move_file(id, target_folder_id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_path(&self, id: &str) -> FileRepositoryResult { - self.repo.get_file_path(id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn move_to_trash(&self, file_id: &str) -> FileRepositoryResult<()> { - self.repo.delete_file(file_id) - .await - .map_err(|e| FileRepositoryError::Other(format!("{}", e))) - } - - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> FileRepositoryResult<()> { - tracing::info!("Restoring file from trash: {} to {}", file_id, original_path); - - match self.repo.get_file(file_id).await { - Ok(_) => { - let path_components: Vec<&str> = original_path.split('/').collect(); - let parent_folder: Option = if path_components.len() > 1 { - tracing::info!("Attempting to restore to parent folder from path: {}", original_path); - None - } else { - None - }; - - match self.repo.move_file(file_id, parent_folder).await { - Ok(_) => { - tracing::info!("Successfully restored file from trash: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to restore file from trash: {}", e); - Err(FileRepositoryError::Other(format!("Failed to restore file: {}", e))) - } - } - }, - Err(e) => { - tracing::error!("File not found in trash: {}", e); - Err(FileRepositoryError::NotFound(file_id.to_string())) - } - } - } - - async fn delete_file_permanently(&self, file_id: &str) -> FileRepositoryResult<()> { - tracing::info!("Permanently deleting file: {}", file_id); - - match self.repo.delete_file(file_id).await { - Ok(_) => { - tracing::info!("Successfully deleted file permanently: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to permanently delete file: {}", e); - Err(FileRepositoryError::Other(format!("Failed to delete file permanently: {}", e))) - } - } - } - - async fn update_file_content(&self, file_id: &str, content: Vec) -> FileRepositoryResult<()> { - tracing::info!("Updating content for file: {}", file_id); - - self.repo.update_file_content(file_id, content) - .await - .map_err(|e| { - tracing::error!("Failed to update file content: {}", e); - FileRepositoryError::Other(format!("Failed to update file content: {}", e)) - }) - } -} - -/// Adaptador que convierte FolderStoragePort a FolderRepository -pub struct DomainFolderRepoAdapter { - repo: Arc, -} - -impl DomainFolderRepoAdapter { - pub fn new(repo: Arc) -> Self { - Self { repo } - } -} - -#[async_trait] -impl FolderRepository for DomainFolderRepoAdapter { - async fn create_folder(&self, name: String, parent_id: Option) -> FolderRepositoryResult { - self.repo.create_folder(name, parent_id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult { - self.repo.get_folder(id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn get_folder_by_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult { - self.repo.get_folder_by_path(storage_path) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn list_folders(&self, parent_id: Option<&str>) -> FolderRepositoryResult> { - self.repo.list_folders(parent_id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - offset: usize, - limit: usize, - include_total: bool - ) -> FolderRepositoryResult<(Vec, Option)> { - self.repo.list_folders_paginated(parent_id, offset, limit, include_total) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn rename_folder(&self, id: &str, new_name: String) -> FolderRepositoryResult { - self.repo.rename_folder(id, new_name) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> FolderRepositoryResult { - self.repo.move_folder(id, new_parent_id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn delete_folder(&self, id: &str) -> FolderRepositoryResult<()> { - self.repo.delete_folder(id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult { - self.repo.folder_exists(storage_path) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult { - self.repo.get_folder_path(id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn folder_exists(&self, _path: &std::path::PathBuf) -> FolderRepositoryResult { - Err(FolderRepositoryError::Other("Not implemented".to_string())) - } - - async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> FolderRepositoryResult { - Err(FolderRepositoryError::Other("Not implemented".to_string())) - } - - async fn move_to_trash(&self, folder_id: &str) -> FolderRepositoryResult<()> { - self.repo.delete_folder(folder_id) - .await - .map_err(|e| FolderRepositoryError::Other(format!("{}", e))) - } - - async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> FolderRepositoryResult<()> { - Err(FolderRepositoryError::Other( - "Restore from trash should be handled by TrashService, not through this adapter".to_string())) - } - - async fn delete_folder_permanently(&self, folder_id: &str) -> FolderRepositoryResult<()> { - self.delete_folder(folder_id).await - } -} diff --git a/src/common/config.rs b/src/common/config.rs index 04b1bf0b..12596c03 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -252,7 +252,10 @@ pub struct AuthConfig { impl Default for AuthConfig { fn default() -> Self { Self { - jwt_secret: "ox1cl0ud-sup3r-s3cr3t-k3y-f0r-t0k3n-s1gn1ng".to_string(), + // SECURITY: This default is intentionally insecure to force operators + // to set OXICLOUD_JWT_SECRET in production. The from_env() method + // will validate this and warn/panic if not configured. + jwt_secret: String::new(), access_token_expiry_secs: 3600, // 1 hora refresh_token_expiry_secs: 2592000, // 30 días hash_memory_cost: 65536, // 64MB @@ -377,6 +380,23 @@ impl AppConfig { if let Ok(jwt_secret) = env::var("OXICLOUD_JWT_SECRET") { config.auth.jwt_secret = jwt_secret; } + + // SECURITY: Validate JWT secret when auth is enabled + if config.features.enable_auth && config.auth.jwt_secret.is_empty() { + // Generate a random secret for this session and warn loudly + use rand_core::{OsRng, RngCore}; + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + let generated_secret: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + config.auth.jwt_secret = generated_secret; + + tracing::error!("=========================================================="); + tracing::error!("SECURITY WARNING: OXICLOUD_JWT_SECRET is not set!"); + tracing::error!("A random secret has been generated for this session."); + tracing::error!("All tokens will be INVALIDATED on restart."); + tracing::error!("Set OXICLOUD_JWT_SECRET env var for production use."); + tracing::error!("=========================================================="); + } if let Ok(access_token_expiry) = env::var("OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS") .map(|v| v.parse::()) { diff --git a/src/common/di.rs b/src/common/di.rs index 7a619dec..73b22000 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1,26 +1,22 @@ use std::path::PathBuf; use std::sync::Arc; -use std::sync::RwLock; use sqlx::PgPool; use crate::application::services::auth_application_service::AuthApplicationService; use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository; -use crate::infrastructure::repositories::file_fs_repository::FileFsRepository; use crate::infrastructure::repositories::trash_fs_repository::TrashFsRepository; use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository; use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; use crate::infrastructure::services::id_mapping_service::IdMappingService; use crate::infrastructure::services::id_mapping_optimizer::IdMappingOptimizer; -use crate::infrastructure::services::cache_manager::StorageCacheManager; use crate::infrastructure::services::file_metadata_cache::FileMetadataCache; -use crate::infrastructure::services::file_content_cache::{FileContentCache, FileContentCacheConfig, SharedFileContentCache}; +use crate::infrastructure::services::file_content_cache::{FileContentCache, FileContentCacheConfig}; use crate::infrastructure::services::buffer_pool::BufferPool; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::folder_service::FolderService; -use crate::application::services::file_service::FileService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::trash_service::TrashService; use crate::application::services::search_service::SearchService; @@ -29,18 +25,32 @@ use crate::application::services::favorites_service::FavoritesService; use crate::application::services::recent_service::RecentService; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::storage_mediator::{StorageMediator, FileSystemStorageMediator}; -use crate::application::ports::inbound::{FileUseCase, FolderUseCase, SearchUseCase}; -use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; +use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::outbound::FolderStoragePort; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; -use crate::infrastructure::repositories::{FileMetadataManager, FilePathResolver, FileFsReadRepository, FileFsWriteRepository}; +use crate::infrastructure::repositories::{FileFsReadRepository, FileFsWriteRepository}; use crate::application::services::{FileUploadService, FileRetrievalService, FileManagementService, AppFileUseCaseFactory}; use crate::common::errors::DomainError; -use crate::common::adapters::{DomainFileRepoAdapter, DomainFolderRepoAdapter}; use crate::domain::services::i18n_service::I18nService; use crate::common::config::AppConfig; +use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort}; +use crate::application::ports::thumbnail_ports::ThumbnailPort; +use crate::application::ports::transcode_ports::ImageTranscodePort; +use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::chunked_upload_ports::ChunkedUploadPort; +use crate::application::ports::compression_ports::CompressionPort; +use crate::application::ports::zip_ports::ZipPort; + +use crate::common::stubs::{ + StubZipPort, StubCompressionPort, StubIdMappingService, StubStorageMediator, + StubFileReadPort, StubFileWritePort, StubFolderStoragePort, + StubI18nService, StubFolderUseCase, StubFileUploadUseCase, + StubFileRetrievalUseCase, StubFileManagementUseCase, StubFileUseCaseFactory, + StubSearchUseCase, +}; /// Fábrica para los diferentes componentes de la aplicación /// @@ -86,18 +96,6 @@ impl AppServiceFactory { // Path service let path_service = Arc::new(PathService::new(self.storage_path.clone())); - // Cache manager - let file_ttl_ms = self.config.cache.file_ttl_ms; - let dir_ttl_ms = self.config.cache.directory_ttl_ms; - let max_entries = self.config.cache.max_entries; - let cache_manager = Arc::new(StorageCacheManager::new(file_ttl_ms, dir_ttl_ms, max_entries)); - - // Iniciar tarea de limpieza de caché en segundo plano - let cache_manager_clone = cache_manager.clone(); - tokio::spawn(async move { - StorageCacheManager::start_cleanup_task(cache_manager_clone).await; - }); - // File content cache for ultra-fast file serving (hot files in RAM) let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig { max_file_size: 10 * 1024 * 1024, // 10MB max per file @@ -162,11 +160,19 @@ impl AppServiceFactory { ); dedup_service.initialize().await?; - tracing::info!("Core services initialized: path service, cache manager, file content cache, ID mapping, thumbnails, write-behind cache, chunked upload, image transcode, dedup"); + // Compression service (gzip) + let compression_service: Arc = Arc::new( + crate::infrastructure::services::compression_service::GzipCompressionService::new() + ); + + tracing::info!("Core services initialized: path service, cache manager, file content cache, ID mapping, thumbnails, write-behind cache, chunked upload, image transcode, dedup, compression"); + + // NOTE: zip_service requires ApplicationServices (FileRetrievalUseCase, FolderUseCase) which are + // created later. It will be set via AppState::with_zip_service() after application services are ready. + // For now we use a placeholder that will be replaced. Ok(CoreServices { path_service, - cache_manager, file_content_cache, id_mapping_service: folder_id_mapping_service, file_id_mapping_service, @@ -176,33 +182,33 @@ impl AppServiceFactory { chunked_upload_service, image_transcode_service, dedup_service, + compression_service, + zip_service: Arc::new(StubZipPort), // Placeholder - replaced after app services init config: self.config.clone(), }) } /// Inicializa los servicios de repositorio pub fn create_repository_services(&self, core: &CoreServices) -> RepositoryServices { - // Storage mediator - con inicialización diferida para folder repository - let folder_repository_holder = Arc::new(RwLock::new(None)); + // Storage mediator - uses stub initially, will be replaced after folder repo is ready + let storage_mediator_stub: Arc = Arc::new( + FileSystemStorageMediator::new_stub() + ); - let storage_mediator = Arc::new(FileSystemStorageMediator::new_with_lazy_folder( - folder_repository_holder.clone(), - core.path_service.clone(), - core.id_mapping_optimizer.clone() - )); - - // Folder repository + // Folder repository — implements FolderStoragePort directly let folder_repository = Arc::new(FolderFsRepository::new( self.storage_path.clone(), - storage_mediator.clone(), + storage_mediator_stub.clone(), core.id_mapping_service.clone(), core.path_service.clone(), )); - // Actualizar el holder para el mediador - if let Ok(mut holder) = folder_repository_holder.write() { - *holder = Some(folder_repository.clone()); - } + // Now create the real storage mediator with the folder repo (as FolderStoragePort) + let storage_mediator: Arc = Arc::new(FileSystemStorageMediator::new( + folder_repository.clone() as Arc, + core.path_service.clone(), + core.id_mapping_optimizer.clone() + )); // Metadata cache let metadata_cache = Arc::new( @@ -225,44 +231,25 @@ impl AppServiceFactory { buffer_pool.clone() )); - // Componentes refactorizados - let metadata_manager = Arc::new(FileMetadataManager::new( - metadata_cache.clone(), - core.config.clone() - )); - - let path_resolver = Arc::new(FilePathResolver::new( - core.path_service.clone(), - storage_mediator.clone(), - core.id_mapping_service.clone() - )); - // File repositories separados para lectura y escritura let file_read_repository = Arc::new(FileFsReadRepository::new( self.storage_path.clone(), - metadata_manager.clone(), - path_resolver.clone(), - core.config.clone(), - Some(parallel_processor.clone()) - )); - - let file_write_repository = Arc::new(FileFsWriteRepository::new( - self.storage_path.clone(), - metadata_manager.clone(), - path_resolver.clone(), - storage_mediator.clone(), - core.config.clone(), - Some(parallel_processor.clone()) - )); - - // File repository con procesamiento paralelo - let file_repository = Arc::new(FileFsRepository::new_with_processor( - self.storage_path.clone(), storage_mediator.clone(), core.file_id_mapping_service.clone(), core.path_service.clone(), metadata_cache.clone(), - parallel_processor + core.config.clone(), + Some(parallel_processor.clone()), + )); + + let file_write_repository = Arc::new(FileFsWriteRepository::new( + self.storage_path.clone(), + storage_mediator.clone(), + core.file_id_mapping_service.clone(), + core.path_service.clone(), + metadata_cache.clone(), + core.config.clone(), + Some(parallel_processor.clone()), )); // I18n repository @@ -284,40 +271,48 @@ impl AppServiceFactory { RepositoryServices { folder_repository, - file_repository, file_read_repository, file_write_repository, i18n_repository, storage_mediator, - metadata_manager, - path_resolver, metadata_cache, trash_repository, } } /// Inicializa los servicios de aplicación - pub fn create_application_services(&self, repos: &RepositoryServices) -> ApplicationServices { + pub fn create_application_services( + &self, + core: &CoreServices, + repos: &RepositoryServices, + trash_service: Option>, + ) -> ApplicationServices { // Servicios principales let folder_service = Arc::new(FolderService::new( repos.folder_repository.clone() )); - let file_service = Arc::new(FileService::new( - repos.file_repository.clone() + // Servicios refactorizados con todos los puertos de infraestructura + let file_upload_service = Arc::new(FileUploadService::new_full( + repos.file_write_repository.clone(), + repos.file_read_repository.clone(), + core.write_behind_cache.clone(), + core.dedup_service.clone(), )); - // Servicios refactorizados - let file_upload_service = Arc::new(FileUploadService::new( - repos.file_write_repository.clone() + let file_retrieval_service = Arc::new(FileRetrievalService::new_full( + repos.file_read_repository.clone(), + core.write_behind_cache.clone(), + core.file_content_cache.clone(), + core.image_transcode_service.clone(), )); - let file_retrieval_service = Arc::new(FileRetrievalService::new( - repos.file_read_repository.clone() - )); - - let file_management_service = Arc::new(FileManagementService::new( - repos.file_write_repository.clone() + // FileManagementService con dedup y trash + let file_management_service = Arc::new(FileManagementService::new_full( + repos.file_write_repository.clone(), + repos.file_read_repository.clone(), + trash_service.clone(), + core.dedup_service.clone(), )); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( @@ -331,7 +326,7 @@ impl AppServiceFactory { // Search service con caché let search_service: Option> = Some(Arc::new(SearchService::new( - repos.file_repository.clone(), + repos.file_read_repository.clone(), repos.folder_repository.clone(), 300, // Cache TTL in seconds (5 minutes) 1000, // Maximum cache entries @@ -342,16 +337,14 @@ impl AppServiceFactory { ApplicationServices { // Tipos concretos para handlers que los necesitan folder_service_concrete: folder_service.clone(), - file_service_concrete: file_service.clone(), // Traits para abstracción folder_service, - file_service, file_upload_service, file_retrieval_service, file_management_service, file_use_case_factory, i18n_service, - trash_service: None, // Se configura después con create_trash_service + trash_service, // Already set via parameter search_service, share_service: None, // Se configura después con create_share_service favorites_service: None, // Se configura después con create_favorites_service @@ -371,14 +364,12 @@ impl AppServiceFactory { let trash_repo = repos.trash_repository.as_ref()?; - // Crear adaptadores - let file_repo_adapter = Arc::new(DomainFileRepoAdapter::new(repos.file_repository.clone())); - let folder_repo_adapter = Arc::new(DomainFolderRepoAdapter::new(repos.folder_repository.clone())); - + // Wire ports directly to TrashService — no adapter layer needed let service = Arc::new(TrashService::new( trash_repo.clone(), - file_repo_adapter, - folder_repo_adapter, + repos.file_read_repository.clone(), + repos.file_write_repository.clone(), + repos.folder_repository.clone(), self.config.storage.trash_retention_days, )); @@ -409,11 +400,16 @@ impl AppServiceFactory { Arc::new(self.config.clone()) )); + // Build a password hasher for share password verification + let password_hasher: Arc = + Arc::new(crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new()); + let service = Arc::new(ShareService::new( Arc::new(self.config.clone()), share_repository, - repos.file_repository.clone(), - repos.folder_repository.clone() + repos.file_read_repository.clone(), + repos.folder_repository.clone(), + password_hasher, )); tracing::info!("File sharing service initialized"); @@ -425,7 +421,10 @@ impl AppServiceFactory { &self, db_pool: &Arc, ) -> Arc { - let service = Arc::new(FavoritesService::new(db_pool.clone())); + let repo = Arc::new( + crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()) + ); + let service = Arc::new(FavoritesService::new(repo)); tracing::info!("Favorites service initialized"); service } @@ -435,8 +434,11 @@ impl AppServiceFactory { &self, db_pool: &Arc, ) -> Arc { + let repo = Arc::new( + crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()) + ); let service = Arc::new(RecentService::new( - db_pool.clone(), + repo, 50 // Maximum recent items per user )); tracing::info!("Recent items service initialized"); @@ -463,22 +465,135 @@ impl AppServiceFactory { tracing::info!("Preloaded {} directory entries into cache", count); } } + + /// Crea el servicio de uso de almacenamiento (requiere base de datos) + pub fn create_storage_usage_service( + &self, + repos: &RepositoryServices, + db_pool: &Arc, + ) -> Arc { + let user_repository = Arc::new( + crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()) + ); + let service = Arc::new( + crate::application::services::storage_usage_service::StorageUsageService::new( + repos.file_read_repository.clone(), + user_repository, + ) + ); + tracing::info!("Storage usage service initialized"); + service + } + + /// Construye el AppState completo usando todos los servicios de la fábrica. + /// + /// Este es el punto de entrada principal que reemplaza toda la lógica manual de `main.rs`. + pub async fn build_app_state( + &self, + db_pool: Option>, + ) -> Result { + // 1. Core services + let core = self.create_core_services().await?; + + // 2. Repository services + let repos = self.create_repository_services(&core); + + // 3. Trash service (needed before application services) + let trash_service = self.create_trash_service(&repos).await; + + // 4. Application services (with trash already wired) + let mut apps = self.create_application_services(&core, &repos, trash_service.clone()); + + // 5. Share service + let share_service = self.create_share_service(&repos); + apps.share_service = share_service.clone(); + + // 6. Database-dependent services + let mut favorites_service: Option> = None; + let mut recent_service: Option> = None; + let mut storage_usage_service: Option> = None; + let mut auth_services: Option = None; + + if let Some(ref pool) = db_pool { + let favs = self.create_favorites_service(pool); + favorites_service = Some(favs.clone()); + apps.favorites_service = Some(favs); + + let recent = self.create_recent_service(pool); + recent_service = Some(recent.clone()); + apps.recent_service = Some(recent); + + storage_usage_service = Some(self.create_storage_usage_service(&repos, pool)); + + // Auth services + if self.config.features.enable_auth { + match crate::infrastructure::auth_factory::create_auth_services( + &self.config, + pool.clone(), + Some(apps.folder_service_concrete.clone()), + ).await { + Ok(services) => { + tracing::info!("Authentication services initialized successfully"); + auth_services = Some(services); + } + Err(e) => { + tracing::error!("Failed to initialize authentication services: {}", e); + } + } + } + } + + // 7. Preload translations + self.preload_translations(&apps.i18n_service).await; + + // 8. Preload cache + self.preload_cache(&repos.metadata_cache).await; + + // 9. Build the ZipService with real application services + let zip_service: Arc = Arc::new( + crate::infrastructure::services::zip_service::ZipService::new( + apps.file_retrieval_service.clone(), + apps.folder_service.clone(), + ) + ); + let mut core = core; + core.zip_service = zip_service; + + // 10. Assemble final AppState + let app_state = AppState { + core, + repositories: repos, + applications: apps, + db_pool, + auth_service: auth_services, + trash_service, + share_service, + favorites_service, + recent_service, + storage_usage_service, + calendar_service: None, + contact_service: None, + }; + + Ok(app_state) + } } /// Contenedor para servicios base #[derive(Clone)] pub struct CoreServices { pub path_service: Arc, - pub cache_manager: Arc, - pub file_content_cache: SharedFileContentCache, + pub file_content_cache: Arc, pub id_mapping_service: Arc, pub file_id_mapping_service: Arc, pub id_mapping_optimizer: Arc, - pub thumbnail_service: Arc, - pub write_behind_cache: Arc, - pub chunked_upload_service: Arc, - pub image_transcode_service: Arc, - pub dedup_service: Arc, + pub thumbnail_service: Arc, + pub write_behind_cache: Arc, + pub chunked_upload_service: Arc, + pub image_transcode_service: Arc, + pub dedup_service: Arc, + pub compression_service: Arc, + pub zip_service: Arc, pub config: AppConfig, } @@ -486,13 +601,10 @@ pub struct CoreServices { #[derive(Clone)] pub struct RepositoryServices { pub folder_repository: Arc, - pub file_repository: Arc, pub file_read_repository: Arc, pub file_write_repository: Arc, pub i18n_repository: Arc, pub storage_mediator: Arc, - pub metadata_manager: Arc, - pub path_resolver: Arc, pub metadata_cache: Arc, pub trash_repository: Option>, } @@ -502,10 +614,8 @@ pub struct RepositoryServices { pub struct ApplicationServices { // Tipos concretos para compatibilidad con handlers existentes pub folder_service_concrete: Arc, - pub file_service_concrete: Arc, // Traits para abstracción pub folder_service: Arc, - pub file_service: Arc, pub file_upload_service: Arc, pub file_retrieval_service: Arc, pub file_management_service: Arc, @@ -544,526 +654,71 @@ pub struct AppState { impl Default for AppState { fn default() -> Self { - // This is just a minimal stub version for auth middleware - // We'll need to create proper instance in main.rs - + // Minimal stub version for auth middleware and route construction. + // Real services are wired in main.rs via AppServiceFactory or manual init. + let config = crate::common::config::AppConfig::default(); let path_service = Arc::new( crate::infrastructure::services::path_service::PathService::new( std::path::PathBuf::from("./storage") ) ); - - // Create stub service implementations - struct DummyIdMappingService; - #[async_trait::async_trait] - impl crate::application::ports::outbound::IdMappingPort for DummyIdMappingService { - async fn get_or_create_id(&self, _path: &crate::domain::services::path_service::StoragePath) -> Result { - Ok("dummy-id".to_string()) - } - - async fn get_path_by_id(&self, _id: &str) -> Result { - Ok(crate::domain::services::path_service::StoragePath::from_string("/")) - } - - async fn update_path(&self, _id: &str, _new_path: &crate::domain::services::path_service::StoragePath) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn remove_id(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn save_changes(&self) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - } - - struct DummyStorageMediator; - #[async_trait::async_trait] - impl crate::application::services::storage_mediator::StorageMediator for DummyStorageMediator { - async fn get_folder_path(&self, _folder_id: &str) -> Result { - Ok(std::path::PathBuf::from("/tmp")) - } - - async fn get_folder_storage_path(&self, _folder_id: &str) -> Result { - Ok(crate::domain::services::path_service::StoragePath::root()) - } - - async fn get_folder(&self, _folder_id: &str) -> Result { - Err(crate::application::services::storage_mediator::StorageMediatorError::NotFound("Stub not implemented".to_string())) - } - - async fn file_exists_at_path(&self, _path: &std::path::Path) -> Result { - Ok(false) - } - - async fn file_exists_at_storage_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { - Ok(false) - } - - async fn folder_exists_at_path(&self, _path: &std::path::Path) -> Result { - Ok(false) - } - - async fn folder_exists_at_storage_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { - Ok(false) - } - - fn resolve_path(&self, _relative_path: &std::path::Path) -> std::path::PathBuf { - std::path::PathBuf::from("/tmp") - } - - fn resolve_storage_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> std::path::PathBuf { - std::path::PathBuf::from("/tmp") - } - - async fn ensure_directory(&self, _path: &std::path::Path) -> Result<(), crate::application::services::storage_mediator::StorageMediatorError> { - Ok(()) - } - - async fn ensure_storage_directory(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<(), crate::application::services::storage_mediator::StorageMediatorError> { - Ok(()) - } - } - - struct DummyFileReadPort; - #[async_trait::async_trait] - impl crate::application::ports::storage_ports::FileReadPort for DummyFileReadPort { - async fn get_file(&self, _id: &str) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn get_file_content(&self, _id: &str) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn get_file_stream(&self, _id: &str) -> Result> + Send>, crate::common::errors::DomainError> { - let empty_stream = futures::stream::empty::>(); - Ok(Box::new(empty_stream)) - } - } - - struct DummyFileWritePort; - #[async_trait::async_trait] - impl crate::application::ports::storage_ports::FileWritePort for DummyFileWritePort { - async fn save_file( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn move_file(&self, _file_id: &str, _target_folder_id: Option) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn get_folder_details(&self, _folder_id: &str) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn get_folder_path_str(&self, _folder_id: &str) -> Result { - Ok("/Mi Carpeta - dummy".to_string()) - } - } - - struct DummyFileStoragePort; - #[async_trait::async_trait] - impl crate::application::ports::outbound::FileStoragePort for DummyFileStoragePort { - async fn save_file( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn save_file_from_stream( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _stream: std::pin::Pin> + Send>>, - ) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn get_file(&self, _id: &str) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn get_file_content(&self, _id: &str) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn get_file_stream(&self, _id: &str) -> Result> + Send>, crate::common::errors::DomainError> { - let empty_stream = futures::stream::empty::>(); - Ok(Box::new(empty_stream)) - } - - async fn get_file_range_stream( - &self, - _id: &str, - _start: u64, - _end: Option - ) -> Result> + Send>, crate::common::errors::DomainError> { - let empty_stream = futures::stream::empty::>(); - Ok(Box::new(empty_stream)) - } - - async fn get_file_mmap(&self, _id: &str) -> Result { - Ok(bytes::Bytes::new()) - } - - async fn move_file(&self, _file_id: &str, _target_folder_id: Option) -> Result { - Ok(crate::domain::entities::file::File::default()) - } - - async fn get_file_path(&self, _id: &str) -> Result { - Ok(crate::domain::services::path_service::StoragePath::from_string("/")) - } - - async fn get_parent_folder_id(&self, _path: &str) -> Result { - Ok("root".to_string()) - } - - async fn update_file_content(&self, _file_id: &str, _content: Vec) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn register_file_deferred( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _size: u64, - ) -> Result<(crate::domain::entities::file::File, std::path::PathBuf), crate::common::errors::DomainError> { - Ok((crate::domain::entities::file::File::default(), std::path::PathBuf::from("/tmp/dummy"))) - } - } - - struct DummyFolderStoragePort; - #[async_trait::async_trait] - impl crate::application::ports::outbound::FolderStoragePort for DummyFolderStoragePort { - async fn create_folder(&self, _name: String, _parent_id: Option) -> Result { - Ok(crate::domain::entities::folder::Folder::default()) - } - - async fn get_folder(&self, _id: &str) -> Result { - Ok(crate::domain::entities::folder::Folder::default()) - } - - async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { - Ok(crate::domain::entities::folder::Folder::default()) - } - - async fn list_folders(&self, _parent_id: Option<&str>) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn list_folders_paginated( - &self, - _parent_id: Option<&str>, - _offset: usize, - _limit: usize, - _include_total: bool - ) -> Result<(Vec, Option), crate::common::errors::DomainError> { - Ok((Vec::new(), Some(0))) - } - - async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { - Ok(crate::domain::entities::folder::Folder::default()) - } - - async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result { - Ok(crate::domain::entities::folder::Folder::default()) - } - - async fn delete_folder(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { - Ok(false) - } - - async fn get_folder_path(&self, _id: &str) -> Result { - Ok(crate::domain::services::path_service::StoragePath::from_string("/")) - } - } - - // File path resolution is handled by other components - - struct DummyI18nService; - #[async_trait::async_trait] - impl crate::domain::services::i18n_service::I18nService for DummyI18nService { - async fn translate(&self, _key: &str, _locale: crate::domain::services::i18n_service::Locale) -> crate::domain::services::i18n_service::I18nResult { - Ok(String::new()) - } - - async fn load_translations(&self, _locale: crate::domain::services::i18n_service::Locale) -> crate::domain::services::i18n_service::I18nResult<()> { - Ok(()) - } - - async fn available_locales(&self) -> Vec { - vec![crate::domain::services::i18n_service::Locale::default()] - } - - async fn is_supported(&self, _locale: crate::domain::services::i18n_service::Locale) -> bool { - true - } - } - - struct DummyFolderUseCase; - #[async_trait::async_trait] - impl crate::application::ports::inbound::FolderUseCase for DummyFolderUseCase { - async fn create_folder(&self, _dto: crate::application::dtos::folder_dto::CreateFolderDto) -> Result { - Ok(crate::application::dtos::folder_dto::FolderDto::default()) - } - - async fn get_folder(&self, _id: &str) -> Result { - Ok(crate::application::dtos::folder_dto::FolderDto::default()) - } - - async fn get_folder_by_path(&self, _path: &str) -> Result { - Ok(crate::application::dtos::folder_dto::FolderDto::default()) - } - - async fn list_folders(&self, _parent_id: Option<&str>) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn list_folders_paginated( - &self, - _parent_id: Option<&str>, - _pagination: &crate::application::dtos::pagination::PaginationRequestDto - ) -> Result, crate::common::errors::DomainError> { - Ok(crate::application::dtos::pagination::PaginatedResponseDto::new( - Vec::new(), - 0, - 10, - 0 - )) - } - - async fn rename_folder(&self, _id: &str, _dto: crate::application::dtos::folder_dto::RenameFolderDto) -> Result { - Ok(crate::application::dtos::folder_dto::FolderDto::default()) - } - - async fn move_folder(&self, _id: &str, _dto: crate::application::dtos::folder_dto::MoveFolderDto) -> Result { - Ok(crate::application::dtos::folder_dto::FolderDto::default()) - } - - async fn delete_folder(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - } - - struct DummyFileUseCase; - #[async_trait::async_trait] - impl crate::application::ports::inbound::FileUseCase for DummyFileUseCase { - async fn upload_file( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - - async fn get_file(&self, _id: &str) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - - async fn get_file_by_path(&self, _path: &str) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - - async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - - async fn update_file(&self, _path: &str, _content: &[u8]) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - - async fn get_file_content(&self, _id: &str) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn get_file_stream(&self, _id: &str) -> Result> + Send>, crate::common::errors::DomainError> { - // Create an empty stream - let empty_stream = futures::stream::empty::>(); - Ok(Box::new(empty_stream)) - } - - async fn move_file(&self, _file_id: &str, _folder_id: Option) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - } - - struct DummyFileUploadUseCase; - #[async_trait::async_trait] - impl crate::application::ports::file_ports::FileUploadUseCase for DummyFileUploadUseCase { - async fn upload_file( - &self, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - } - - struct DummyFileRetrievalUseCase; - #[async_trait::async_trait] - impl crate::application::ports::file_ports::FileRetrievalUseCase for DummyFileRetrievalUseCase { - async fn get_file(&self, _id: &str) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn get_file_content(&self, _id: &str) -> Result, crate::common::errors::DomainError> { - Ok(Vec::new()) - } - - async fn get_file_stream(&self, _id: &str) -> Result> + Send>, crate::common::errors::DomainError> { - // Create an empty stream - let empty_stream = futures::stream::empty::>(); - Ok(Box::new(empty_stream)) - } - } - - struct DummyFileManagementUseCase; - #[async_trait::async_trait] - impl crate::application::ports::file_ports::FileManagementUseCase for DummyFileManagementUseCase { - async fn move_file(&self, _file_id: &str, _folder_id: Option) -> Result { - Ok(crate::application::dtos::file_dto::FileDto::default()) - } - - async fn delete_file(&self, _id: &str) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - } - - struct DummyFileUseCaseFactory; - impl crate::application::ports::file_ports::FileUseCaseFactory for DummyFileUseCaseFactory { - fn create_file_upload_use_case(&self) -> std::sync::Arc { - std::sync::Arc::new(DummyFileUploadUseCase) - } - - fn create_file_retrieval_use_case(&self) -> std::sync::Arc { - std::sync::Arc::new(DummyFileRetrievalUseCase) - } - - fn create_file_management_use_case(&self) -> std::sync::Arc { - std::sync::Arc::new(DummyFileManagementUseCase) - } - } - - struct DummyI18nApplicationService {} - - // Need to implement the actual service to match the type signature in DI container - impl DummyI18nApplicationService { - fn dummy() -> crate::application::services::i18n_application_service::I18nApplicationService { - // We need to create an actual I18nApplicationService - crate::application::services::i18n_application_service::I18nApplicationService::new( - Arc::new(DummyI18nService) as Arc - ) - } - } - - // Create service instances - let id_mapping_service = Arc::new(DummyIdMappingService) as Arc; - let storage_mediator = Arc::new(DummyStorageMediator) as Arc; - let i18n_repository = Arc::new(DummyI18nService) as Arc; - let folder_service = Arc::new(DummyFolderUseCase) as Arc; - let file_service = Arc::new(DummyFileUseCase) as Arc; - let file_upload_service = Arc::new(DummyFileUploadUseCase) as Arc; - let file_retrieval_service = Arc::new(DummyFileRetrievalUseCase) as Arc; - let file_management_service = Arc::new(DummyFileManagementUseCase) as Arc; - let file_use_case_factory = Arc::new(DummyFileUseCaseFactory) as Arc; - + + // Create service instances from the stubs module + let id_mapping_service = Arc::new(StubIdMappingService) as Arc; + let storage_mediator = Arc::new(StubStorageMediator) as Arc; + let i18n_repository = Arc::new(StubI18nService) as Arc; + let folder_service = Arc::new(StubFolderUseCase) as Arc; + let file_upload_service = Arc::new(StubFileUploadUseCase) as Arc; + let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) as Arc; + let file_management_service = Arc::new(StubFileManagementUseCase) as Arc; + let file_use_case_factory = Arc::new(StubFileUseCaseFactory) as Arc; + // Create dummy ID mapping service for files let dummy_file_id_mapping = Arc::new(IdMappingService::dummy()); let dummy_id_optimizer = Arc::new(IdMappingOptimizer::new(dummy_file_id_mapping.clone())); - + // Create file content cache for stub let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig::default())); - + // Create dummy thumbnail service - let dummy_thumbnail_service = Arc::new( + let dummy_thumbnail_service: Arc = Arc::new( crate::infrastructure::services::thumbnail_service::ThumbnailService::new( &std::path::PathBuf::from("./storage"), 100, 10 * 1024 * 1024, ) ); - + // Create dummy write-behind cache - let dummy_write_behind_cache = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); - + let dummy_write_behind_cache: Arc = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); + // Create dummy chunked upload service - let dummy_chunked_upload_service = Arc::new( + let dummy_chunked_upload_service: Arc = Arc::new( crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( std::path::PathBuf::from("./storage/.uploads") ) ); - + // Create dummy image transcode service - let dummy_image_transcode_service = Arc::new( + let dummy_image_transcode_service: Arc = Arc::new( crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( &std::path::PathBuf::from("./storage"), 100, 10 * 1024 * 1024, ) ); - + // Create dummy dedup service - let dummy_dedup_service = Arc::new( + let dummy_dedup_service: Arc = Arc::new( crate::infrastructure::services::dedup_service::DedupService::new( &std::path::PathBuf::from("./storage") ) ); - - // This creates the core services needed for basic functionality + + // Core services using stubs let core_services = CoreServices { path_service: path_service.clone(), - cache_manager: Arc::new(crate::infrastructure::services::cache_manager::StorageCacheManager::default()), file_content_cache, id_mapping_service: id_mapping_service.clone(), file_id_mapping_service: dummy_file_id_mapping, @@ -1073,71 +728,50 @@ impl Default for AppState { chunked_upload_service: dummy_chunked_upload_service, image_transcode_service: dummy_image_transcode_service, dedup_service: dummy_dedup_service, + compression_service: Arc::new(StubCompressionPort) as Arc, + zip_service: Arc::new(StubZipPort) as Arc, config: config.clone(), }; - - // Create dummy metadata cache + + // Dummy metadata cache let dummy_metadata_cache = Arc::new(FileMetadataCache::default_with_config(config.clone())); - - // Create empty repository implementations + + // Repository services using stubs let repository_services = RepositoryServices { - folder_repository: Arc::new(DummyFolderStoragePort) as Arc, - file_repository: Arc::new(DummyFileStoragePort) as Arc, - file_read_repository: Arc::new(DummyFileReadPort) as Arc, - file_write_repository: Arc::new(DummyFileWritePort) as Arc, + folder_repository: Arc::new(StubFolderStoragePort) as Arc, + file_read_repository: Arc::new(StubFileReadPort) as Arc, + file_write_repository: Arc::new(StubFileWritePort) as Arc, i18n_repository, storage_mediator: storage_mediator.clone(), - metadata_manager: Arc::new(crate::infrastructure::repositories::FileMetadataManager::default()), - path_resolver: Arc::new(crate::infrastructure::repositories::file_path_resolver::FilePathResolver::new( - path_service.clone(), - storage_mediator.clone(), - id_mapping_service.clone() - )), metadata_cache: dummy_metadata_cache, - trash_repository: None, // No trash repository in minimal mode + trash_repository: None, }; - - // Create dummy search use case - struct DummySearchUseCase; - #[async_trait::async_trait] - impl crate::application::ports::inbound::SearchUseCase for DummySearchUseCase { - async fn search( - &self, - _criteria: crate::application::dtos::search_dto::SearchCriteriaDto - ) -> Result { - Ok(crate::application::dtos::search_dto::SearchResultsDto::empty()) - } - - async fn clear_search_cache(&self) -> Result<(), crate::common::errors::DomainError> { - Ok(()) - } - } - - // Create dummy concrete services for compatibility - let dummy_folder_storage = Arc::new(DummyFolderStoragePort) as Arc; - let dummy_file_storage = Arc::new(DummyFileStoragePort) as Arc; - let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage)); - let file_service_concrete = Arc::new(FileService::new(dummy_file_storage)); - // Create application services + // Dummy concrete services for compatibility + let dummy_folder_storage = Arc::new(StubFolderStoragePort) as Arc; + let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage)); + + // Dummy I18nApplicationService + let dummy_i18n_app_service = crate::application::services::i18n_application_service::I18nApplicationService::new( + Arc::new(StubI18nService) as Arc + ); + + // Application services using stubs let application_services = ApplicationServices { folder_service_concrete: folder_service_concrete.clone(), - file_service_concrete: file_service_concrete.clone(), folder_service, - file_service, file_upload_service, file_retrieval_service, file_management_service, file_use_case_factory, - i18n_service: Arc::new(DummyI18nApplicationService::dummy()), - trash_service: None, // No trash service in minimal mode - search_service: Some(Arc::new(DummySearchUseCase) as Arc), - share_service: None, // No share service in minimal mode - favorites_service: None, // No favorites service in minimal mode - recent_service: None, // No recent service in minimal mode + i18n_service: Arc::new(dummy_i18n_app_service), + trash_service: None, + search_service: Some(Arc::new(StubSearchUseCase) as Arc), + share_service: None, + favorites_service: None, + recent_service: None, }; - - // Return a minimal app state + Self { core: core_services, repositories: repository_services, @@ -1182,6 +816,60 @@ impl AppState { self } + /// Creates a minimal AppState for route construction. + /// + /// Uses `Default` stubs for infrastructure services, then overlays the real + /// application-level services that arrive as parameters from `main.rs`. + /// This keeps `routes.rs` free of any `crate::infrastructure` references. + pub fn for_routing( + folder_service: Arc, + file_retrieval_service: Arc, + file_upload_service: Arc, + file_management_service: Arc, + folder_use_case: Arc, + i18n_service: Option>, + trash_service: Option>, + search_service: Option>, + share_service: Option>, + favorites_service: Option>, + recent_service: Option>, + ) -> Self { + let mut state = Self::default(); + + // Override application services with real ones + state.applications.folder_service_concrete = folder_service.clone(); + state.applications.folder_service = folder_use_case; + state.applications.file_upload_service = file_upload_service; + state.applications.file_retrieval_service = file_retrieval_service.clone(); + state.applications.file_management_service = file_management_service; + + if let Some(i18n) = i18n_service { + state.applications.i18n_service = i18n; + } + + state.applications.trash_service = trash_service.clone(); + state.applications.search_service = search_service.clone(); + state.applications.share_service = share_service.clone(); + state.applications.favorites_service = favorites_service.clone(); + state.applications.recent_service = recent_service.clone(); + + // Also set top-level optional services + state.trash_service = trash_service; + state.share_service = share_service; + state.favorites_service = favorites_service; + state.recent_service = recent_service; + + // Create real ZipService with the actual file/folder services + state.core.zip_service = Arc::new( + crate::infrastructure::services::zip_service::ZipService::new( + file_retrieval_service as Arc, + folder_service.clone() as Arc, + ) + ); + + state + } + pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self { self.auth_service = Some(auth_services); self @@ -1221,4 +909,9 @@ impl AppState { self.contact_service = Some(contact_service); self } + + pub fn with_zip_service(mut self, zip_service: Arc) -> Self { + self.core.zip_service = zip_service; + self + } } \ No newline at end of file diff --git a/src/common/mod.rs b/src/common/mod.rs index 1073261f..0254d7a2 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,6 +1,4 @@ pub mod errors; pub mod config; pub mod di; -pub mod db; -pub mod auth_factory; -pub mod adapters; \ No newline at end of file +pub mod stubs; \ No newline at end of file diff --git a/src/common/stubs.rs b/src/common/stubs.rs new file mode 100644 index 00000000..6a8d7cd2 --- /dev/null +++ b/src/common/stubs.rs @@ -0,0 +1,752 @@ +//! Stub/Dummy implementations for dependency injection. +//! +//! These no-op implementations are used exclusively by `AppState::default()` +//! to provide a minimal, valid state for the auth middleware and route +//! construction before the real services are wired in `main.rs`. +//! +//! **None of these stubs should ever handle real user requests.** + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; + +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto}; +use crate::application::dtos::pagination::{PaginatedResponseDto, PaginationRequestDto}; +use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; +use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; +use crate::application::ports::file_ports::{ + FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, + UploadStrategy, OptimizedFileContent, +}; +use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::outbound::IdMappingPort; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::ports::zip_ports::ZipPort; +use crate::application::services::storage_mediator::{StorageMediator, StorageMediatorError}; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::entities::folder::Folder; +use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale}; +use crate::domain::services::path_service::StoragePath; + +// --------------------------------------------------------------------------- +// ZipPort +// --------------------------------------------------------------------------- + +/// Placeholder ZipPort that always errors. Replaced after application services +/// are fully initialised. +pub struct StubZipPort; + +#[async_trait] +impl ZipPort for StubZipPort { + async fn create_folder_zip( + &self, + _folder_id: &str, + _folder_name: &str, + ) -> Result, DomainError> { + Err(DomainError::internal_error( + "ZipService", + "ZipService not initialized", + )) + } +} + +// --------------------------------------------------------------------------- +// CompressionPort +// --------------------------------------------------------------------------- + +pub struct StubCompressionPort; + +#[async_trait] +impl CompressionPort for StubCompressionPort { + async fn compress_data( + &self, + _data: &[u8], + _level: CompressionLevel, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn decompress_data(&self, _compressed_data: &[u8]) -> Result, DomainError> { + Ok(Vec::new()) + } + + fn should_compress(&self, _mime_type: &str, _size: u64) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// IdMappingPort +// --------------------------------------------------------------------------- + +pub struct StubIdMappingService; + +#[async_trait] +impl IdMappingPort for StubIdMappingService { + async fn get_or_create_id( + &self, + _path: &StoragePath, + ) -> Result { + Ok("dummy-id".to_string()) + } + + async fn get_path_by_id(&self, _id: &str) -> Result { + Ok(StoragePath::from_string("/")) + } + + async fn update_path( + &self, + _id: &str, + _new_path: &StoragePath, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn remove_id(&self, _id: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn save_changes(&self) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// StorageMediator +// --------------------------------------------------------------------------- + +pub struct StubStorageMediator; + +#[async_trait] +impl StorageMediator for StubStorageMediator { + async fn get_folder_path( + &self, + _folder_id: &str, + ) -> Result { + Ok(PathBuf::from("/tmp")) + } + + async fn get_folder_storage_path( + &self, + _folder_id: &str, + ) -> Result { + Ok(StoragePath::root()) + } + + async fn get_folder( + &self, + _folder_id: &str, + ) -> Result { + Err(StorageMediatorError::NotFound( + "Stub not implemented".to_string(), + )) + } + + async fn file_exists_at_path( + &self, + _path: &Path, + ) -> Result { + Ok(false) + } + + async fn file_exists_at_storage_path( + &self, + _storage_path: &StoragePath, + ) -> Result { + Ok(false) + } + + async fn folder_exists_at_path( + &self, + _path: &Path, + ) -> Result { + Ok(false) + } + + async fn folder_exists_at_storage_path( + &self, + _storage_path: &StoragePath, + ) -> Result { + Ok(false) + } + + fn resolve_path(&self, _relative_path: &Path) -> PathBuf { + PathBuf::from("/tmp") + } + + fn resolve_storage_path(&self, _storage_path: &StoragePath) -> PathBuf { + PathBuf::from("/tmp") + } + + async fn ensure_directory( + &self, + _path: &Path, + ) -> Result<(), StorageMediatorError> { + Ok(()) + } + + async fn ensure_storage_directory( + &self, + _storage_path: &StoragePath, + ) -> Result<(), StorageMediatorError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// IdMappingPort +// --------------------------------------------------------------------------- + +pub struct StubIdMappingPort; + +#[async_trait] +impl IdMappingPort for StubIdMappingPort { + async fn get_or_create_id(&self, _path: &StoragePath) -> Result { + Ok("stub-id".to_string()) + } + async fn get_path_by_id(&self, _id: &str) -> Result { + Ok(StoragePath::from_string("/")) + } + async fn update_path(&self, _id: &str, _new_path: &StoragePath) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_id(&self, _id: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn save_changes(&self) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FileReadPort +// --------------------------------------------------------------------------- + +pub struct StubFileReadPort; + +#[async_trait] +impl FileReadPort for StubFileReadPort { + async fn get_file(&self, _id: &str) -> Result { + Ok(File::default()) + } + + async fn list_files( + &self, + _folder_id: Option<&str>, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn get_file_content(&self, _id: &str) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn get_file_stream( + &self, + _id: &str, + ) -> Result> + Send>, DomainError> { + let empty_stream = futures::stream::empty::>(); + Ok(Box::new(empty_stream)) + } + + async fn get_file_range_stream( + &self, + _id: &str, + _start: u64, + _end: Option, + ) -> Result> + Send>, DomainError> { + let empty_stream = futures::stream::empty::>(); + Ok(Box::new(empty_stream)) + } + + async fn get_file_mmap(&self, _id: &str) -> Result { + Ok(Bytes::new()) + } + + async fn get_file_path(&self, _id: &str) -> Result { + Ok(StoragePath::from_string("/")) + } + + async fn get_parent_folder_id(&self, _path: &str) -> Result { + Ok("root".to_string()) + } +} + +// --------------------------------------------------------------------------- +// FileWritePort +// --------------------------------------------------------------------------- + +pub struct StubFileWritePort; + +#[async_trait] +impl FileWritePort for StubFileWritePort { + async fn save_file( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _content: Vec, + ) -> Result { + Ok(File::default()) + } + + async fn save_file_from_stream( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _stream: std::pin::Pin> + Send>>, + ) -> Result { + Ok(File::default()) + } + + async fn move_file( + &self, + _file_id: &str, + _target_folder_id: Option, + ) -> Result { + Ok(File::default()) + } + + async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn update_file_content( + &self, + _file_id: &str, + _content: Vec, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn register_file_deferred( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _size: u64, + ) -> Result<(File, PathBuf), DomainError> { + Ok((File::default(), PathBuf::from("/tmp/dummy"))) + } + + async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn restore_from_trash(&self, _file_id: &str, _original_path: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn delete_file_permanently(&self, _file_id: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FolderStoragePort +// --------------------------------------------------------------------------- + +pub struct StubFolderStoragePort; + +#[async_trait] +impl FolderRepository for StubFolderStoragePort { + async fn create_folder( + &self, + _name: String, + _parent_id: Option, + ) -> Result { + Ok(Folder::default()) + } + + async fn get_folder(&self, _id: &str) -> Result { + Ok(Folder::default()) + } + + async fn get_folder_by_path( + &self, + _storage_path: &StoragePath, + ) -> Result { + Ok(Folder::default()) + } + + async fn list_folders( + &self, + _parent_id: Option<&str>, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn list_folders_paginated( + &self, + _parent_id: Option<&str>, + _offset: usize, + _limit: usize, + _include_total: bool, + ) -> Result<(Vec, Option), DomainError> { + Ok((Vec::new(), Some(0))) + } + + async fn rename_folder( + &self, + _id: &str, + _new_name: String, + ) -> Result { + Ok(Folder::default()) + } + + async fn move_folder( + &self, + _id: &str, + _new_parent_id: Option<&str>, + ) -> Result { + Ok(Folder::default()) + } + + async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn folder_exists( + &self, + _storage_path: &StoragePath, + ) -> Result { + Ok(false) + } + + async fn get_folder_path( + &self, + _id: &str, + ) -> Result { + Ok(StoragePath::from_string("/")) + } + + async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// I18nService +// --------------------------------------------------------------------------- + +pub struct StubI18nService; + +#[async_trait] +impl I18nService for StubI18nService { + async fn translate(&self, _key: &str, _locale: Locale) -> I18nResult { + Ok(String::new()) + } + + async fn load_translations(&self, _locale: Locale) -> I18nResult<()> { + Ok(()) + } + + async fn available_locales(&self) -> Vec { + vec![Locale::default()] + } + + async fn is_supported(&self, _locale: Locale) -> bool { + true + } +} + +// --------------------------------------------------------------------------- +// FolderUseCase +// --------------------------------------------------------------------------- + +pub struct StubFolderUseCase; + +#[async_trait] +impl FolderUseCase for StubFolderUseCase { + async fn create_folder( + &self, + _dto: CreateFolderDto, + ) -> Result { + Ok(FolderDto::default()) + } + + async fn get_folder(&self, _id: &str) -> Result { + Ok(FolderDto::default()) + } + + async fn get_folder_by_path( + &self, + _path: &str, + ) -> Result { + Ok(FolderDto::default()) + } + + async fn list_folders( + &self, + _parent_id: Option<&str>, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn list_folders_paginated( + &self, + _parent_id: Option<&str>, + _pagination: &PaginationRequestDto, + ) -> Result, DomainError> { + Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) + } + + async fn rename_folder( + &self, + _id: &str, + _dto: RenameFolderDto, + ) -> Result { + Ok(FolderDto::default()) + } + + async fn move_folder( + &self, + _id: &str, + _dto: MoveFolderDto, + ) -> Result { + Ok(FolderDto::default()) + } + + async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FileUploadUseCase +// --------------------------------------------------------------------------- + +pub struct StubFileUploadUseCase; + +#[async_trait] +impl FileUploadUseCase for StubFileUploadUseCase { + async fn upload_file( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _content: Vec, + ) -> Result { + Ok(FileDto::default()) + } + + async fn smart_upload( + &self, + _name: String, + _folder_id: Option, + _content_type: String, + _chunks: Vec, + _total_size: usize, + ) -> Result<(FileDto, UploadStrategy), DomainError> { + Ok((FileDto::default(), UploadStrategy::Buffered)) + } + + async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result { + Ok(FileDto::default()) + } + + async fn update_file(&self, _path: &str, _content: &[u8]) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FileRetrievalUseCase +// --------------------------------------------------------------------------- + +pub struct StubFileRetrievalUseCase; + +#[async_trait] +impl FileRetrievalUseCase for StubFileRetrievalUseCase { + async fn get_file(&self, _id: &str) -> Result { + Ok(FileDto::default()) + } + + async fn list_files( + &self, + _folder_id: Option<&str>, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn get_file_content(&self, _id: &str) -> Result, DomainError> { + Ok(Vec::new()) + } + + async fn get_file_stream( + &self, + _id: &str, + ) -> Result> + Send>, DomainError> { + let empty_stream = futures::stream::empty::>(); + Ok(Box::new(empty_stream)) + } + + async fn get_file_optimized( + &self, + _id: &str, + _accept_webp: bool, + _prefer_original: bool, + ) -> Result<(FileDto, OptimizedFileContent), DomainError> { + Ok((FileDto::default(), OptimizedFileContent::Bytes { + data: Bytes::new(), + mime_type: String::new(), + was_transcoded: false, + })) + } + + async fn get_file_range_stream( + &self, + _id: &str, + _start: u64, + _end: Option, + ) -> Result> + Send>, DomainError> { + let empty_stream = futures::stream::empty::>(); + Ok(Box::new(empty_stream)) + } + + async fn get_file_by_path(&self, _path: &str) -> Result { + Err(DomainError::not_found("File", "stub")) + } +} + +// --------------------------------------------------------------------------- +// FileManagementUseCase +// --------------------------------------------------------------------------- + +pub struct StubFileManagementUseCase; + +#[async_trait] +impl FileManagementUseCase for StubFileManagementUseCase { + async fn move_file( + &self, + _file_id: &str, + _folder_id: Option, + ) -> Result { + Ok(FileDto::default()) + } + + async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn delete_with_cleanup( + &self, + _id: &str, + _user_id: &str, + ) -> Result { + Ok(false) + } +} + +// --------------------------------------------------------------------------- +// FileUseCaseFactory +// --------------------------------------------------------------------------- + +pub struct StubFileUseCaseFactory; + +impl FileUseCaseFactory for StubFileUseCaseFactory { + fn create_file_upload_use_case(&self) -> Arc { + Arc::new(StubFileUploadUseCase) + } + + fn create_file_retrieval_use_case(&self) -> Arc { + Arc::new(StubFileRetrievalUseCase) + } + + fn create_file_management_use_case(&self) -> Arc { + Arc::new(StubFileManagementUseCase) + } +} + +// --------------------------------------------------------------------------- +// SearchUseCase +// --------------------------------------------------------------------------- + +pub struct StubSearchUseCase; + +#[async_trait] +impl SearchUseCase for StubSearchUseCase { + async fn search( + &self, + _criteria: SearchCriteriaDto, + ) -> Result { + Ok(SearchResultsDto::empty()) + } + + async fn clear_search_cache(&self) -> Result<(), DomainError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// MetadataCachePort +// --------------------------------------------------------------------------- + +use crate::application::ports::cache_ports::{MetadataCachePort, CachedMetadataDto, ContentCachePort}; + +pub struct StubMetadataCachePort; + +#[async_trait] +impl MetadataCachePort for StubMetadataCachePort { + async fn get_metadata(&self, _path: &Path) -> Option { + None + } + + async fn is_file(&self, _path: &Path) -> Option { + None + } + + async fn refresh_metadata(&self, path: &Path) -> Result { + Ok(CachedMetadataDto { + path: path.to_path_buf(), + exists: false, + is_file: false, + size: None, + mime_type: None, + created_at: None, + modified_at: None, + }) + } + + async fn invalidate(&self, _path: &Path) {} + + async fn invalidate_directory(&self, _dir_path: &Path) {} +} + +// --------------------------------------------------------------------------- +// ContentCachePort +// --------------------------------------------------------------------------- + +pub struct StubContentCachePort; + +#[async_trait] +impl ContentCachePort for StubContentCachePort { + fn should_cache(&self, _size: usize) -> bool { + false + } + + async fn get(&self, _file_id: &str) -> Option<(Bytes, String, String)> { + None + } + + async fn put(&self, _file_id: String, _content: Bytes, _etag: String, _content_type: String) {} + + async fn invalidate(&self, _file_id: &str) {} + + async fn clear(&self) {} +} diff --git a/src/domain/entities/contact.rs b/src/domain/entities/contact.rs index 11232d6f..a81730fc 100644 --- a/src/domain/entities/contact.rs +++ b/src/domain/entities/contact.rs @@ -3,28 +3,79 @@ use uuid::Uuid; #[derive(Debug, Clone)] pub struct AddressBook { - pub id: Uuid, - pub name: String, - pub owner_id: String, - pub description: Option, - pub color: Option, - pub is_public: bool, - pub created_at: DateTime, - pub updated_at: DateTime, + id: Uuid, + name: String, + owner_id: String, + description: Option, + color: Option, + is_public: bool, + created_at: DateTime, + updated_at: DateTime, +} + +impl AddressBook { + /// Creates a new AddressBook with generated id and timestamps + pub fn new( + name: String, + owner_id: String, + description: Option, + color: Option, + is_public: bool, + ) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + name, + owner_id, + description, + color, + is_public, + created_at: now, + updated_at: now, + } + } + + /// Reconstructs from persistence (no validation) + pub fn from_raw( + id: Uuid, + name: String, + owner_id: String, + description: Option, + color: Option, + is_public: bool, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { id, name, owner_id, description, color, is_public, created_at, updated_at } + } + + // --- Getters --- + pub fn id(&self) -> &Uuid { &self.id } + pub fn name(&self) -> &str { &self.name } + pub fn owner_id(&self) -> &str { &self.owner_id } + pub fn description(&self) -> Option<&str> { self.description.as_deref() } + pub fn color(&self) -> Option<&str> { self.color.as_deref() } + pub fn is_public(&self) -> bool { self.is_public } + pub fn created_at(&self) -> &DateTime { &self.created_at } + pub fn updated_at(&self) -> &DateTime { &self.updated_at } + + // --- Setters for mutable operations --- + pub fn set_name(&mut self, name: String) { self.name = name; self.updated_at = Utc::now(); } + pub fn set_description(&mut self, description: Option) { self.description = description; self.updated_at = Utc::now(); } + pub fn set_color(&mut self, color: Option) { self.color = color; self.updated_at = Utc::now(); } + pub fn set_is_public(&mut self, is_public: bool) { self.is_public = is_public; self.updated_at = Utc::now(); } + pub fn set_updated_at(&mut self, updated_at: DateTime) { self.updated_at = updated_at; } } impl Default for AddressBook { fn default() -> Self { - Self { - id: Uuid::new_v4(), - name: "Default Address Book".to_string(), - owner_id: "default".to_string(), - description: None, - color: None, - is_public: false, - created_at: Utc::now(), - updated_at: Utc::now(), - } + Self::new( + "Default Address Book".to_string(), + "default".to_string(), + None, + None, + false, + ) } } @@ -55,6 +106,192 @@ pub struct Address { #[derive(Debug, Clone)] pub struct Contact { + id: Uuid, + address_book_id: Uuid, + uid: String, + full_name: Option, + first_name: Option, + last_name: Option, + nickname: Option, + email: Vec, + phone: Vec, + address: Vec
, + organization: Option, + title: Option, + notes: Option, + photo_url: Option, + birthday: Option, + anniversary: Option, + vcard: String, + etag: String, + created_at: DateTime, + updated_at: DateTime, +} + +impl Contact { + /// Creates a new Contact with generated id, uid, etag and timestamps + #[allow(clippy::too_many_arguments)] + pub fn new( + address_book_id: Uuid, + full_name: Option, + first_name: Option, + last_name: Option, + nickname: Option, + email: Vec, + phone: Vec, + address: Vec
, + organization: Option, + title: Option, + notes: Option, + photo_url: Option, + birthday: Option, + anniversary: Option, + vcard: String, + ) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + address_book_id, + uid: format!("{}@oxicloud", Uuid::new_v4()), + full_name, + first_name, + last_name, + nickname, + email, + phone, + address, + organization, + title, + notes, + photo_url, + birthday, + anniversary, + vcard, + etag: Uuid::new_v4().to_string(), + created_at: now, + updated_at: now, + } + } + + /// Reconstructs from persistence (no validation) + #[allow(clippy::too_many_arguments)] + pub fn from_raw( + id: Uuid, + address_book_id: Uuid, + uid: String, + full_name: Option, + first_name: Option, + last_name: Option, + nickname: Option, + email: Vec, + phone: Vec, + address: Vec
, + organization: Option, + title: Option, + notes: Option, + photo_url: Option, + birthday: Option, + anniversary: Option, + vcard: String, + etag: String, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, address_book_id, uid, full_name, first_name, last_name, nickname, + email, phone, address, organization, title, notes, photo_url, + birthday, anniversary, vcard, etag, created_at, updated_at, + } + } + + // --- Getters --- + pub fn id(&self) -> &Uuid { &self.id } + pub fn address_book_id(&self) -> &Uuid { &self.address_book_id } + pub fn uid(&self) -> &str { &self.uid } + pub fn full_name(&self) -> Option<&str> { self.full_name.as_deref() } + pub fn first_name(&self) -> Option<&str> { self.first_name.as_deref() } + pub fn last_name(&self) -> Option<&str> { self.last_name.as_deref() } + pub fn nickname(&self) -> Option<&str> { self.nickname.as_deref() } + pub fn email(&self) -> &[Email] { &self.email } + pub fn phone(&self) -> &[Phone] { &self.phone } + pub fn address(&self) -> &[Address] { &self.address } + pub fn organization(&self) -> Option<&str> { self.organization.as_deref() } + pub fn title(&self) -> Option<&str> { self.title.as_deref() } + pub fn notes(&self) -> Option<&str> { self.notes.as_deref() } + pub fn photo_url(&self) -> Option<&str> { self.photo_url.as_deref() } + pub fn birthday(&self) -> Option<&NaiveDate> { self.birthday.as_ref() } + pub fn anniversary(&self) -> Option<&NaiveDate> { self.anniversary.as_ref() } + pub fn vcard(&self) -> &str { &self.vcard } + pub fn etag(&self) -> &str { &self.etag } + pub fn created_at(&self) -> &DateTime { &self.created_at } + pub fn updated_at(&self) -> &DateTime { &self.updated_at } + + // --- Owned getters for persistence layer bind() calls --- + pub fn full_name_owned(&self) -> Option { self.full_name.clone() } + pub fn first_name_owned(&self) -> Option { self.first_name.clone() } + pub fn last_name_owned(&self) -> Option { self.last_name.clone() } + pub fn nickname_owned(&self) -> Option { self.nickname.clone() } + pub fn organization_owned(&self) -> Option { self.organization.clone() } + pub fn title_owned(&self) -> Option { self.title.clone() } + pub fn notes_owned(&self) -> Option { self.notes.clone() } + pub fn photo_url_owned(&self) -> Option { self.photo_url.clone() } + + // --- Setters for mutable operations (contact_service.rs needs these) --- + pub fn set_full_name(&mut self, v: Option) { self.full_name = v; } + pub fn set_first_name(&mut self, v: Option) { self.first_name = v; } + pub fn set_last_name(&mut self, v: Option) { self.last_name = v; } + pub fn set_nickname(&mut self, v: Option) { self.nickname = v; } + pub fn set_organization(&mut self, v: Option) { self.organization = v; } + pub fn set_title(&mut self, v: Option) { self.title = v; } + pub fn set_notes(&mut self, v: Option) { self.notes = v; } + pub fn set_photo_url(&mut self, v: Option) { self.photo_url = v; } + pub fn set_birthday(&mut self, v: Option) { self.birthday = v; } + pub fn set_anniversary(&mut self, v: Option) { self.anniversary = v; } + pub fn set_vcard(&mut self, vcard: String) { self.vcard = vcard; } + pub fn set_etag(&mut self, etag: String) { self.etag = etag; } + pub fn set_updated_at(&mut self, updated_at: DateTime) { self.updated_at = updated_at; } + pub fn set_address_book_id(&mut self, id: Uuid) { self.address_book_id = id; } + pub fn set_uid(&mut self, uid: String) { self.uid = uid; } + + // --- Collection mutators --- + pub fn push_email(&mut self, e: Email) { self.email.push(e); } + pub fn push_phone(&mut self, p: Phone) { self.phone.push(p); } + pub fn set_email(&mut self, email: Vec) { self.email = email; } + pub fn set_phone(&mut self, phone: Vec) { self.phone = phone; } + pub fn set_address(&mut self, address: Vec
) { self.address = address; } + pub fn email_is_empty(&self) -> bool { self.email.is_empty() } + pub fn phone_is_empty(&self) -> bool { self.phone.is_empty() } + + // --- Consuming methods for ownership transfer --- + pub fn into_email(self) -> Vec { self.email } + pub fn into_parts(self) -> ContactParts { + ContactParts { + id: self.id, + address_book_id: self.address_book_id, + uid: self.uid, + full_name: self.full_name, + first_name: self.first_name, + last_name: self.last_name, + nickname: self.nickname, + email: self.email, + phone: self.phone, + address: self.address, + organization: self.organization, + title: self.title, + notes: self.notes, + photo_url: self.photo_url, + birthday: self.birthday, + anniversary: self.anniversary, + vcard: self.vcard, + etag: self.etag, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +/// Holds all Contact fields by value, for when ownership transfer is needed +pub struct ContactParts { pub id: Uuid, pub address_book_id: Uuid, pub uid: String, @@ -79,6 +316,7 @@ pub struct Contact { impl Default for Contact { fn default() -> Self { + let now = Utc::now(); Self { id: Uuid::new_v4(), address_book_id: Uuid::new_v4(), @@ -98,29 +336,53 @@ impl Default for Contact { anniversary: None, vcard: "BEGIN:VCARD\nVERSION:3.0\nEND:VCARD".to_string(), etag: Uuid::new_v4().to_string(), - created_at: Utc::now(), - updated_at: Utc::now(), + created_at: now, + updated_at: now, } } } #[derive(Debug, Clone)] pub struct ContactGroup { - pub id: Uuid, - pub address_book_id: Uuid, - pub name: String, - pub created_at: DateTime, - pub updated_at: DateTime, + id: Uuid, + address_book_id: Uuid, + name: String, + created_at: DateTime, + updated_at: DateTime, +} + +impl ContactGroup { + /// Creates a new ContactGroup with generated id and timestamps + pub fn new(address_book_id: Uuid, name: String) -> Self { + let now = Utc::now(); + Self { id: Uuid::new_v4(), address_book_id, name, created_at: now, updated_at: now } + } + + /// Reconstructs from persistence + pub fn from_raw( + id: Uuid, + address_book_id: Uuid, + name: String, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { id, address_book_id, name, created_at, updated_at } + } + + // --- Getters --- + pub fn id(&self) -> &Uuid { &self.id } + pub fn address_book_id(&self) -> &Uuid { &self.address_book_id } + pub fn name(&self) -> &str { &self.name } + pub fn created_at(&self) -> &DateTime { &self.created_at } + pub fn updated_at(&self) -> &DateTime { &self.updated_at } + + // --- Setters --- + pub fn set_name(&mut self, name: String) { self.name = name; self.updated_at = Utc::now(); } + pub fn set_updated_at(&mut self, updated_at: DateTime) { self.updated_at = updated_at; } } impl Default for ContactGroup { fn default() -> Self { - Self { - id: Uuid::new_v4(), - address_book_id: Uuid::new_v4(), - name: "New Group".to_string(), - created_at: Utc::now(), - updated_at: Utc::now(), - } + ContactGroup::new(Uuid::new_v4(), "New Group".to_string()) } } \ No newline at end of file diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index ab688497..98368d95 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -3,14 +3,14 @@ use chrono::{DateTime, Utc, Duration}; #[derive(Debug, Clone)] pub struct Session { - pub id: String, - pub user_id: String, - pub refresh_token: String, - pub expires_at: DateTime, - pub ip_address: Option, - pub user_agent: Option, - pub created_at: DateTime, - pub revoked: bool, + id: String, + user_id: String, + refresh_token: String, + expires_at: DateTime, + ip_address: Option, + user_agent: Option, + created_at: DateTime, + revoked: bool, } impl Session { @@ -21,6 +21,13 @@ impl Session { user_agent: Option, expires_in_days: i64, ) -> Self { + if user_id.is_empty() { + panic!("Session user_id cannot be empty"); + } + if refresh_token.is_empty() { + panic!("Session refresh_token cannot be empty"); + } + let now = Utc::now(); Self { id: Uuid::new_v4().to_string(), @@ -33,6 +40,30 @@ impl Session { revoked: false, } } + + /// Reconstruct a Session from persisted data (e.g. database row). + /// Skips ID generation — uses the provided values directly. + pub fn from_raw( + id: String, + user_id: String, + refresh_token: String, + expires_at: DateTime, + ip_address: Option, + user_agent: Option, + created_at: DateTime, + revoked: bool, + ) -> Self { + Self { + id, + user_id, + refresh_token, + expires_at, + ip_address, + user_agent, + created_at, + revoked, + } + } // Getters pub fn id(&self) -> &str { @@ -50,6 +81,14 @@ impl Session { pub fn expires_at(&self) -> DateTime { self.expires_at } + + pub fn ip_address(&self) -> Option<&str> { + self.ip_address.as_deref() + } + + pub fn user_agent(&self) -> Option<&str> { + self.user_agent.as_deref() + } pub fn created_at(&self) -> DateTime { self.created_at diff --git a/src/domain/entities/share.rs b/src/domain/entities/share.rs index 7499da6c..5f8d0077 100644 --- a/src/domain/entities/share.rs +++ b/src/domain/entities/share.rs @@ -6,23 +6,23 @@ pub use super::entity_errors::ShareError; #[derive(Debug, Clone, PartialEq)] pub struct Share { - pub id: String, - pub item_id: String, - pub item_type: ShareItemType, - pub token: String, - pub password_hash: Option, - pub expires_at: Option, - pub permissions: SharePermissions, - pub created_at: u64, - pub created_by: String, - pub access_count: u64, + id: String, + item_id: String, + item_type: ShareItemType, + token: String, + password_hash: Option, + expires_at: Option, + permissions: SharePermissions, + created_at: u64, + created_by: String, + access_count: u64, } #[derive(Debug, Clone, PartialEq)] pub struct SharePermissions { - pub read: bool, - pub write: bool, - pub reshare: bool, + read: bool, + write: bool, + reshare: bool, } #[derive(Debug, Clone, PartialEq)] @@ -80,6 +80,74 @@ impl Share { }) } + /// Reconstruct a Share from persisted data (e.g. filesystem/database). + /// Skips validation and ID generation — uses the provided values directly. + pub fn from_raw( + id: String, + item_id: String, + item_type: ShareItemType, + token: String, + password_hash: Option, + expires_at: Option, + permissions: SharePermissions, + created_at: u64, + created_by: String, + access_count: u64, + ) -> Self { + Self { + id, + item_id, + item_type, + token, + password_hash, + expires_at, + permissions, + created_at, + created_by, + access_count, + } + } + + // ── Getters ── + + pub fn id(&self) -> &str { + &self.id + } + + pub fn item_id(&self) -> &str { + &self.item_id + } + + pub fn item_type(&self) -> &ShareItemType { + &self.item_type + } + + pub fn token(&self) -> &str { + &self.token + } + + pub fn expires_at(&self) -> Option { + self.expires_at + } + + pub fn permissions(&self) -> &SharePermissions { + &self.permissions + } + + pub fn created_at(&self) -> u64 { + self.created_at + } + + pub fn created_by(&self) -> &str { + &self.created_by + } + + pub fn access_count(&self) -> u64 { + self.access_count + } + + // ── Builder-style modifiers (immutable) ── + pub fn with_permissions(mut self, permissions: SharePermissions) -> Self { self.permissions = permissions; self @@ -118,15 +186,17 @@ impl Share { self } - pub fn verify_password(&self, password: &str) -> bool { - match &self.password_hash { - Some(hash) => { - // In a real implementation, use a proper password hashing function like bcrypt - // For simplicity, we're just comparing strings here - hash == password - } - None => true, - } + /// Returns whether this share requires a password to access. + pub fn has_password(&self) -> bool { + self.password_hash.is_some() + } + + /// Returns a reference to the password hash, if one is set. + /// + /// Password verification should be performed externally via PasswordHasherPort + /// to keep cryptographic dependencies out of the domain layer. + pub fn password_hash(&self) -> Option<&str> { + self.password_hash.as_deref() } } @@ -138,13 +208,25 @@ impl SharePermissions { reshare, } } + + pub fn read(&self) -> bool { + self.read + } + + pub fn write(&self) -> bool { + self.write + } + + pub fn reshare(&self) -> bool { + self.reshare + } } -impl ToString for ShareItemType { - fn to_string(&self) -> String { +impl std::fmt::Display for ShareItemType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ShareItemType::File => "file".to_string(), - ShareItemType::Folder => "folder".to_string(), + ShareItemType::File => write!(f, "file"), + ShareItemType::Folder => write!(f, "folder"), } } } @@ -177,15 +259,15 @@ mod tests { ) .unwrap(); - assert_eq!(share.item_id, "test_file_id"); - assert_eq!(share.item_type, ShareItemType::File); - assert_eq!(share.created_by, "user123"); - assert_eq!(share.permissions.read, true); - assert_eq!(share.permissions.write, false); - assert_eq!(share.permissions.reshare, false); - assert!(share.password_hash.is_none()); - assert!(share.expires_at.is_none()); - assert_eq!(share.access_count, 0); + assert_eq!(share.item_id(), "test_file_id"); + assert_eq!(*share.item_type(), ShareItemType::File); + assert_eq!(share.created_by(), "user123"); + assert_eq!(share.permissions().read(), true); + assert_eq!(share.permissions().write(), false); + assert_eq!(share.permissions().reshare(), false); + assert!(!share.has_password()); + assert!(share.expires_at().is_none()); + assert_eq!(share.access_count(), 0); } #[test] @@ -233,4 +315,36 @@ mod tests { assert_eq!(ShareItemType::try_from("FILE").unwrap(), ShareItemType::File); assert!(ShareItemType::try_from("invalid").is_err()); } + + #[test] + fn test_has_password_with_hash() { + let share = Share::new( + "test_file_id".to_string(), + ShareItemType::File, + "user123".to_string(), + None, + Some("some_hash_value".to_string()), + None, + ) + .unwrap(); + + assert!(share.has_password()); + assert_eq!(share.password_hash(), Some("some_hash_value")); + } + + #[test] + fn test_has_password_without_hash() { + let share = Share::new( + "test_file_id".to_string(), + ShareItemType::File, + "user123".to_string(), + None, + None, // No password + None, + ) + .unwrap(); + + assert!(!share.has_password()); + assert_eq!(share.password_hash(), None); + } } diff --git a/src/domain/entities/trashed_item.rs b/src/domain/entities/trashed_item.rs index 283cd056..d7280498 100644 --- a/src/domain/entities/trashed_item.rs +++ b/src/domain/entities/trashed_item.rs @@ -9,14 +9,14 @@ pub enum TrashedItemType { #[derive(Debug, Clone)] pub struct TrashedItem { - pub id: Uuid, - pub original_id: Uuid, - pub user_id: Uuid, - pub item_type: TrashedItemType, - pub name: String, - pub original_path: String, - pub trashed_at: DateTime, - pub deletion_date: DateTime, // Fecha de eliminación permanente automática + id: Uuid, + original_id: Uuid, + user_id: Uuid, + item_type: TrashedItemType, + name: String, + original_path: String, + trashed_at: DateTime, + deletion_date: DateTime, } impl TrashedItem { @@ -41,6 +41,64 @@ impl TrashedItem { } } + /// Reconstruct a TrashedItem from persisted data (e.g. JSON index). + /// Skips ID generation — uses the provided values directly. + pub fn from_raw( + id: Uuid, + original_id: Uuid, + user_id: Uuid, + item_type: TrashedItemType, + name: String, + original_path: String, + trashed_at: DateTime, + deletion_date: DateTime, + ) -> Self { + Self { + id, + original_id, + user_id, + item_type, + name, + original_path, + trashed_at, + deletion_date, + } + } + + // ── Getters ── + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn original_id(&self) -> Uuid { + self.original_id + } + + pub fn user_id(&self) -> Uuid { + self.user_id + } + + pub fn item_type(&self) -> &TrashedItemType { + &self.item_type + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn original_path(&self) -> &str { + &self.original_path + } + + pub fn trashed_at(&self) -> DateTime { + self.trashed_at + } + + pub fn deletion_date(&self) -> DateTime { + self.deletion_date + } + pub fn days_until_deletion(&self) -> i64 { let now = Utc::now(); (self.deletion_date - now).num_days().max(0) diff --git a/src/domain/repositories/file_repository.rs b/src/domain/repositories/file_repository.rs index f087d480..bcb7ef24 100644 --- a/src/domain/repositories/file_repository.rs +++ b/src/domain/repositories/file_repository.rs @@ -1,297 +1,143 @@ +//! Puerto de persistencia del dominio para la entidad File. +//! +//! Define el contrato que cualquier implementación de almacenamiento de archivos +//! debe cumplir. Este trait vive en el dominio porque File es una entidad core +//! del sistema y sus contratos de persistencia pertenecen a la capa de dominio, +//! siguiendo los principios de Clean/Hexagonal Architecture. +//! +//! Las implementaciones concretas (filesystem, PostgreSQL, S3, etc.) viven en +//! la capa de infraestructura. + +use std::path::PathBuf; +use std::pin::Pin; + use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; + use crate::domain::entities::file::File; use crate::domain::services::path_service::StoragePath; use crate::common::errors::DomainError; -use futures::Stream; -use bytes::Bytes; -/** - * Comprehensive error types for file repository operations. - * - * This enum represents all possible error conditions that can occur during file repository - * operations, providing detailed context for error handling across the application. - */ -#[derive(Debug, thiserror::Error)] -pub enum FileRepositoryError { - /// Returned when a requested file cannot be found by ID or path - #[error("File not found: {0}")] - NotFound(String), - - /// Returned when attempting to create a file at a location where one already exists - #[error("File already exists: {0}")] - AlreadyExists(String), - - /// Returned when a provided file path is invalid or malformed - #[error("Invalid file path: {0}")] - InvalidPath(String), - - /// Returned when an operation is not supported by the current implementation - #[error("Operation not supported: {0}")] - OperationNotSupported(String), - - /// Wraps standard I/O errors from the filesystem - #[error("IO Error: {0}")] - IoError(#[from] std::io::Error), - - /// Indicates errors in the path-to-ID mapping system - #[error("Mapping error: {0}")] - MappingError(String), - - /// Specific errors related to ID mapping operations - #[error("ID Mapping error: {0}")] - IdMappingError(String), - - /// Returned when an operation exceeds its timeout threshold - #[error("Timeout error: {0}")] - Timeout(String), - - /// Propagates domain model errors to the repository layer - #[error("Domain error: {0}")] - DomainError(#[from] DomainError), - - /// Catch-all for other unspecified errors - #[error("Other error: {0}")] - Other(String), +// ───────────────────────────────────────────────────── +// FileReadRepository — operaciones de lectura/consulta +// ───────────────────────────────────────────────────── + +/// Puerto del dominio para **lectura** de archivos. +/// +/// Encapsula toda operación que consulta estado sin modificarlo: +/// obtener, listar, contenido, stream, mmap, rango, resolución de rutas. +#[async_trait] +pub trait FileReadRepository: Send + Sync + 'static { + /// Obtiene un archivo por su ID. + async fn get_file(&self, id: &str) -> Result; + + /// Lista archivos en una carpeta. + async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; + + /// Obtiene contenido completo como bytes (solo archivos pequeños/medianos). + async fn get_file_content(&self, id: &str) -> Result, DomainError>; + + /// Obtiene contenido como stream (ideal para archivos grandes). + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError>; + + /// Stream de un rango de bytes (HTTP Range Requests, video seek). + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError>; + + /// Memory-map de archivo para acceso zero-copy (10–100 MB). + async fn get_file_mmap(&self, id: &str) -> Result; + + /// Obtiene la ruta de almacenamiento lógica de un archivo. + async fn get_file_path(&self, id: &str) -> Result; + + /// Obtiene el ID de la carpeta padre a partir de una ruta (WebDAV). + async fn get_parent_folder_id(&self, path: &str) -> Result; } -/** - * Type alias for results of file repository operations. - * - * Provides a consistent return type for all repository methods, containing - * either a successful value or a FileRepositoryError. - */ -pub type FileRepositoryResult = Result; +// ───────────────────────────────────────────────────── +// FileWriteRepository — operaciones de escritura/mutación +// ───────────────────────────────────────────────────── -/** - * Repository interface defining all file storage operations. - * - * This trait represents the primary port for file operations in the domain model, - * following the hexagonal architecture pattern. It defines the contract that any - * file storage implementation must fulfill, abstracting away implementation details - * like filesystem specifics, cloud storage, or database operations. - * - * All implementations must be thread-safe (Send + Sync) and have a 'static lifetime - * to support the async operations in the system. - */ +/// Puerto del dominio para **escritura** de archivos. +/// +/// Cubre: upload (buffered + streaming), move, delete, update, +/// y el registro diferido para write-behind cache. #[async_trait] -pub trait FileRepository: Send + Sync + 'static { - /** - * Creates and saves a new file from binary content. - * - * This method handles new file creation with automatic ID generation, - * content storage, and metadata registration. - * - * @param name The filename with extension - * @param folder_id Optional ID of parent folder, None for root - * @param content_type MIME type of the file - * @param content Binary data of the file - * @return A File entity with generated metadata on success, error otherwise - */ - async fn save_file_from_bytes( +pub trait FileWriteRepository: Send + Sync + 'static { + /// Guarda un nuevo archivo desde bytes. + async fn save_file( &self, name: String, folder_id: Option, content_type: String, content: Vec, - ) -> FileRepositoryResult; - - /** - * Creates and saves a new file from a stream of bytes. - * - * STREAMING UPLOAD: Writes chunks directly to disk as they arrive, - * avoiding memory accumulation for large files. This is the preferred - * method for handling uploads of any size. - * - * Benefits: - * - Constant memory usage regardless of file size - * - Faster time-to-first-byte for large files - * - Better handling of slow network connections - * - * @param name The filename with extension - * @param folder_id Optional ID of parent folder, None for root - * @param content_type MIME type of the file - * @param stream Async stream of byte chunks - * @return A File entity with generated metadata on success, error otherwise - */ + ) -> Result; + + /// Upload en streaming — escribe chunks a disco sin acumular en RAM. async fn save_file_from_stream( &self, name: String, folder_id: Option, content_type: String, - stream: std::pin::Pin> + Send>>, - ) -> FileRepositoryResult; - - /** - * Saves a file with a predetermined ID. - * - * Similar to save_file_from_bytes but allows specifying the ID, - * useful for restoring files or migrations. - * - * @param id Predefined unique ID for the file - * @param name The filename with extension - * @param folder_id Optional ID of parent folder, None for root - * @param content_type MIME type of the file - * @param content Binary data of the file - * @return The created File entity on success, error otherwise - */ - async fn save_file_with_id( + stream: Pin> + Send>>, + ) -> Result; + + /// Mueve un archivo a otra carpeta. + async fn move_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result; + + /// Elimina un archivo. + async fn delete_file(&self, id: &str) -> Result<(), DomainError>; + + /// Actualiza el contenido de un archivo existente. + async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError>; + + /// Registra metadatos de archivo SIN escribir contenido a disco (write-behind). + /// + /// Devuelve `(File, PathBuf)` donde `PathBuf` es la ruta destino para la + /// escritura diferida que realizará el `WriteBehindCache`. + async fn register_file_deferred( &self, - id: String, name: String, folder_id: Option, content_type: String, - content: Vec, - ) -> FileRepositoryResult; - - /** - * Retrieves a file entity by its unique ID. - * - * @param id The unique identifier of the file - * @return The File entity if found, NotFound error otherwise - */ - async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult; - - /** - * Lists all files within a specified folder. - * - * @param folder_id Optional folder ID to list files from, None for root - * @return Vector of File entities in the folder - */ - async fn list_files(&self, folder_id: Option<&str>) -> FileRepositoryResult>; - - /** - * Deletes a file by ID. - * - * @param id The unique identifier of the file to delete - * @return Success or error - */ - async fn delete_file(&self, id: &str) -> FileRepositoryResult<()>; - - /** - * Deletes a file and removes its mapping entries. - * - * More thorough than delete_file as it also purges ID mappings, - * useful for permanent deletions. - * - * @param id The unique identifier of the file to delete - * @return Success or error - */ - async fn delete_file_entry(&self, id: &str) -> FileRepositoryResult<()>; - - /** - * Retrieves the complete file content as a byte vector. - * - * This method loads the entire file into memory, so it should - * only be used for reasonably sized files. - * - * @param id The unique identifier of the file - * @return The file's binary content - */ - async fn get_file_content(&self, id: &str) -> FileRepositoryResult>; - - /** - * Retrieves file content as an asynchronous stream of bytes. - * - * Preferred for large files as it avoids loading everything into memory at once. - * - * @param id The unique identifier of the file - * @return A stream that yields chunks of file data - */ - #[allow(clippy::type_complexity)] - async fn get_file_stream(&self, id: &str) -> FileRepositoryResult> + Send>>; - - /** - * Retrieves a range of file content as an asynchronous stream. - * - * Used for HTTP Range Requests to support: - * - Video seeking - * - Resumable downloads - * - Parallel chunk downloads - * - * @param id The unique identifier of the file - * @param start Starting byte position (inclusive) - * @param end Ending byte position (inclusive), None means until EOF - * @return A stream that yields chunks of file data for the specified range - */ - #[allow(clippy::type_complexity)] - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option - ) -> FileRepositoryResult> + Send>>; - - /** - * Memory-maps a file for zero-copy access. - * - * Uses memory-mapped I/O for efficient reading of medium-sized files (10-100MB). - * The kernel handles page faults and caching, providing near-zero-copy performance. - * - * IMPORTANT: This is synchronous I/O wrapped in spawn_blocking. - * Best for files that will be read sequentially in full. - * - * @param id The unique identifier of the file - * @return Bytes containing the memory-mapped file content - */ - async fn get_file_mmap(&self, id: &str) -> FileRepositoryResult; - - /** - * Moves a file to a different folder. - * - * @param id The unique identifier of the file to move - * @param target_folder_id The destination folder ID, None for root - * @return The updated File entity after the move - */ - async fn move_file(&self, id: &str, target_folder_id: Option) -> FileRepositoryResult; - - /** - * Retrieves the storage path for a file. - * - * @param id The unique identifier of the file - * @return The StoragePath object representing the file's location - */ - async fn get_file_path(&self, id: &str) -> FileRepositoryResult; - - /** - * Moves a file to the trash system. - * - * Instead of permanent deletion, this marks the file as trashed - * and relocates it to the trash storage area. - * - * @param file_id The unique identifier of the file to trash - * @return Success or error - */ - async fn move_to_trash(&self, file_id: &str) -> FileRepositoryResult<()>; - - /** - * Restores a file from the trash to its original location. - * - * @param file_id The unique identifier of the file to restore - * @param original_path The original path where the file was located before trashing - * @return Success or error - */ - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> FileRepositoryResult<()>; - - /** - * Permanently deletes a file from the trash system. - * - * This operation is not reversible and removes the file completely. - * Used primarily by the trash cleanup service. - * - * @param file_id The unique identifier of the file to permanently delete - * @return Success or error - */ - async fn delete_file_permanently(&self, file_id: &str) -> FileRepositoryResult<()>; - - /** - * Updates the content of an existing file. - * - * This method replaces the binary content of a file while preserving its - * metadata like ID, creation timestamp, and location. - * - * @param file_id The unique identifier of the file to update - * @param content The new binary content for the file - * @return Success or error - */ - async fn update_file_content(&self, file_id: &str, content: Vec) -> FileRepositoryResult<()>; -} \ No newline at end of file + size: u64, + ) -> Result<(File, PathBuf), DomainError>; + + // ── Trash operations ── + + /// Mueve un archivo a la papelera + async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; + + /// Restaura un archivo desde la papelera a su ubicación original + async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError>; + + /// Elimina un archivo permanentemente (usado por la papelera) + async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError>; +} + +// ───────────────────────────────────────────────────── +// FileRepository — supertrait unificado +// ───────────────────────────────────────────────────── + +/// Puerto unificado para persistencia de archivos. +/// +/// Es un supertrait de `FileReadRepository + FileWriteRepository`. +/// Cualquier tipo que implemente ambos ports obtiene `FileRepository` +/// automáticamente vía blanket impl. +pub trait FileRepository: FileReadRepository + FileWriteRepository {} + +/// Blanket implementation: cualquier tipo que implemente ambos ports +/// es automáticamente un FileRepository. +impl FileRepository for T {} diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index dce7ab03..3eb83610 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -1,100 +1,69 @@ +//! Puerto de persistencia del dominio para la entidad Folder. +//! +//! Define el contrato que cualquier implementación de almacenamiento de carpetas +//! debe cumplir. Este trait vive en el dominio porque Folder es una entidad core +//! del sistema y sus contratos de persistencia pertenecen a la capa de dominio, +//! siguiendo los principios de Clean/Hexagonal Architecture. +//! +//! Las implementaciones concretas (filesystem, PostgreSQL, S3, etc.) viven en +//! la capa de infraestructura. + use async_trait::async_trait; + use crate::domain::entities::folder::Folder; use crate::domain::services::path_service::StoragePath; use crate::common::errors::DomainError; -/// Error types for folder repository operations -#[derive(Debug, thiserror::Error)] -pub enum FolderRepositoryError { - #[error("Folder not found: {0}")] - NotFound(String), - - #[error("Folder already exists: {0}")] - AlreadyExists(String), - - #[error("Invalid folder path: {0}")] - InvalidPath(String), - - #[error("Operation not supported: {0}")] - OperationNotSupported(String), - - #[error("IO Error: {0}")] - IoError(#[from] std::io::Error), - - #[error("Mapping error: {0}")] - MappingError(String), - - #[error("Validation error: {0}")] - ValidationError(String), - - #[error("Domain error: {0}")] - DomainError(#[from] DomainError), - - #[error("Other error: {0}")] - Other(String), -} - -/// Result type for folder repository operations -pub type FolderRepositoryResult = Result; - -/// Repository interface for folder operations (primary port) +/// Puerto del dominio para persistencia de carpetas. +/// +/// Define las operaciones CRUD y de gestión necesarias para +/// la entidad Folder en el sistema de almacenamiento. #[async_trait] pub trait FolderRepository: Send + Sync + 'static { - /// Creates a new folder - async fn create_folder(&self, name: String, parent_id: Option) -> FolderRepositoryResult; + /// Crea una nueva carpeta + async fn create_folder(&self, name: String, parent_id: Option) -> Result; - /// Gets a folder by its ID - async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult; + /// Obtiene una carpeta por su ID + async fn get_folder(&self, id: &str) -> Result; - /// Gets a folder by its path - async fn get_folder_by_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult; + /// Obtiene una carpeta por su ruta de almacenamiento + async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result; - /// Lists all folders in a parent folder (use with caution for large directories) - async fn list_folders(&self, parent_id: Option<&str>) -> FolderRepositoryResult>; + /// Lista carpetas dentro de una carpeta padre + async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - /// Lists folders in a parent folder with pagination support - /// - /// * `parent_id` - Optional parent folder ID - /// * `offset` - Number of folders to skip - /// * `limit` - Maximum number of folders to return - /// * `include_total` - If true, returns the total count of folders as well + /// Lista carpetas con paginación async fn list_folders_paginated( &self, - parent_id: Option<&str>, - offset: usize, + parent_id: Option<&str>, + offset: usize, limit: usize, include_total: bool - ) -> FolderRepositoryResult<(Vec, Option)>; + ) -> Result<(Vec, Option), DomainError>; - /// Renames a folder - async fn rename_folder(&self, id: &str, new_name: String) -> FolderRepositoryResult; + /// Renombra una carpeta + async fn rename_folder(&self, id: &str, new_name: String) -> Result; - /// Moves a folder to a new parent - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> FolderRepositoryResult; + /// Mueve una carpeta a otro padre + async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result; - /// Deletes a folder - async fn delete_folder(&self, id: &str) -> FolderRepositoryResult<()>; + /// Elimina una carpeta + async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; - /// Checks if a folder exists at the given path - async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult; + /// Verifica si existe una carpeta en la ruta dada + async fn folder_exists(&self, storage_path: &StoragePath) -> Result; - /// Gets the storage path for a folder - async fn get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult; - - /// Legacy method - checks if a folder exists at the given PathBuf path - #[deprecated(note = "Use folder_exists_at_storage_path instead")] - async fn folder_exists(&self, path: &std::path::PathBuf) -> FolderRepositoryResult; - - /// Legacy method - gets a folder by its PathBuf path - #[deprecated(note = "Use get_folder_by_storage_path instead")] - async fn get_folder_by_path(&self, path: &std::path::PathBuf) -> FolderRepositoryResult; - - /// Moves a folder to trash - async fn move_to_trash(&self, folder_id: &str) -> FolderRepositoryResult<()>; - - /// Restores a folder from trash - async fn restore_from_trash(&self, folder_id: &str, original_path: &str) -> FolderRepositoryResult<()>; - - /// Permanently deletes a folder (used for trash cleanup) - async fn delete_folder_permanently(&self, folder_id: &str) -> FolderRepositoryResult<()>; -} \ No newline at end of file + /// Obtiene la ruta de una carpeta + async fn get_folder_path(&self, id: &str) -> Result; + + // ── Trash operations ── + + /// Mueve una carpeta a la papelera + async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError>; + + /// Restaura una carpeta desde la papelera a su ubicación original + async fn restore_from_trash(&self, folder_id: &str, original_path: &str) -> Result<(), DomainError>; + + /// Elimina una carpeta permanentemente (usado por la papelera) + async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>; +} diff --git a/src/infrastructure/adapters/contact_storage_adapter.rs b/src/infrastructure/adapters/contact_storage_adapter.rs index ca43eb05..2443f053 100644 --- a/src/infrastructure/adapters/contact_storage_adapter.rs +++ b/src/infrastructure/adapters/contact_storage_adapter.rs @@ -58,12 +58,12 @@ impl ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; // Check if user is owner - if address_book.owner_id == user_id { + if address_book.owner_id() == user_id { return Ok(address_book); } // Check if address book is public - if address_book.is_public { + if address_book.is_public() { return Ok(address_book); } @@ -84,7 +84,7 @@ impl ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; // Owner always has write access - if address_book.owner_id == user_id { + if address_book.owner_id() == user_id { return Ok(address_book); } @@ -132,41 +132,41 @@ impl ContactStorageAdapter { fn generate_vcard(contact: &Contact) -> String { let mut vcard = String::from("BEGIN:VCARD\nVERSION:3.0\n"); - if let Some(ref full_name) = contact.full_name { + if let Some(full_name) = contact.full_name() { vcard.push_str(&format!("FN:{}\n", full_name)); } - if contact.first_name.is_some() || contact.last_name.is_some() { - let last = contact.last_name.as_deref().unwrap_or(""); - let first = contact.first_name.as_deref().unwrap_or(""); + if contact.first_name().is_some() || contact.last_name().is_some() { + let last = contact.last_name().unwrap_or(""); + let first = contact.first_name().unwrap_or(""); vcard.push_str(&format!("N:{};{};;;\n", last, first)); } - if let Some(ref nickname) = contact.nickname { + if let Some(nickname) = contact.nickname() { vcard.push_str(&format!("NICKNAME:{}\n", nickname)); } - for email in &contact.email { + for email in contact.email() { vcard.push_str(&format!("EMAIL;TYPE={}:{}\n", email.r#type.to_uppercase(), email.email)); } - for phone in &contact.phone { + for phone in contact.phone() { vcard.push_str(&format!("TEL;TYPE={}:{}\n", phone.r#type.to_uppercase(), phone.number)); } - if let Some(ref org) = contact.organization { + if let Some(org) = contact.organization() { vcard.push_str(&format!("ORG:{}\n", org)); } - if let Some(ref title) = contact.title { + if let Some(title) = contact.title() { vcard.push_str(&format!("TITLE:{}\n", title)); } - if let Some(ref notes) = contact.notes { + if let Some(notes) = contact.notes() { vcard.push_str(&format!("NOTE:{}\n", notes)); } - vcard.push_str(&format!("UID:{}\n", contact.uid)); + vcard.push_str(&format!("UID:{}\n", contact.uid())); vcard.push_str("END:VCARD\n"); vcard @@ -176,16 +176,13 @@ impl ContactStorageAdapter { #[async_trait] impl AddressBookUseCase for ContactStorageAdapter { async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result { - let address_book = AddressBook { - id: Uuid::new_v4(), - name: dto.name, - owner_id: dto.owner_id, - description: dto.description, - color: dto.color, - is_public: dto.is_public.unwrap_or(false), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }; + let address_book = AddressBook::new( + dto.name, + dto.owner_id, + dto.description, + dto.color, + dto.is_public.unwrap_or(false), + ); let created = self.address_book_repository.create_address_book(address_book).await?; Ok(AddressBookDto::from(created)) @@ -198,18 +195,18 @@ impl AddressBookUseCase for ContactStorageAdapter { let mut address_book = self.check_write_access(&uuid, &update.user_id).await?; if let Some(name) = update.name { - address_book.name = name; + address_book.set_name(name); } if let Some(description) = update.description { - address_book.description = Some(description); + address_book.set_description(Some(description)); } if let Some(color) = update.color { - address_book.color = Some(color); + address_book.set_color(Some(color)); } if let Some(is_public) = update.is_public { - address_book.is_public = is_public; + address_book.set_is_public(is_public); } - address_book.updated_at = chrono::Utc::now(); + address_book.set_updated_at(chrono::Utc::now()); let updated = self.address_book_repository.update_address_book(address_book).await?; Ok(AddressBookDto::from(updated)) @@ -224,7 +221,7 @@ impl AddressBookUseCase for ContactStorageAdapter { .await? .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can delete address book")); } @@ -261,7 +258,7 @@ impl AddressBookUseCase for ContactStorageAdapter { .await? .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can share")); } @@ -277,7 +274,7 @@ impl AddressBookUseCase for ContactStorageAdapter { .await? .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can unshare")); } @@ -293,7 +290,7 @@ impl AddressBookUseCase for ContactStorageAdapter { .await? .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - if address_book.owner_id != user_id { + if address_book.owner_id() != user_id { return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can view shares")); } @@ -309,34 +306,35 @@ impl ContactUseCase for ContactStorageAdapter { // Check write access self.check_write_access(&address_book_id, &dto.user_id).await?; - let contact = Contact { - id: Uuid::new_v4(), + let now = chrono::Utc::now(); + let mut contact = Contact::from_raw( + Uuid::new_v4(), address_book_id, - uid: format!("{}@oxicloud", Uuid::new_v4()), - full_name: dto.full_name, - first_name: dto.first_name, - last_name: dto.last_name, - nickname: dto.nickname, - email: dto.email.into_iter().map(Self::dto_to_email).collect(), - phone: dto.phone.into_iter().map(Self::dto_to_phone).collect(), - address: dto.address.into_iter().map(Self::dto_to_address).collect(), - organization: dto.organization, - title: dto.title, - notes: dto.notes, - photo_url: dto.photo_url, - birthday: dto.birthday, - anniversary: dto.anniversary, - vcard: String::new(), - etag: Uuid::new_v4().to_string(), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }; + format!("{}@oxicloud", Uuid::new_v4()), + dto.full_name, + dto.first_name, + dto.last_name, + dto.nickname, + dto.email.into_iter().map(Self::dto_to_email).collect(), + dto.phone.into_iter().map(Self::dto_to_phone).collect(), + dto.address.into_iter().map(Self::dto_to_address).collect(), + dto.organization, + dto.title, + dto.notes, + dto.photo_url, + dto.birthday, + dto.anniversary, + String::new(), + Uuid::new_v4().to_string(), + now, + now, + ); // Generate vCard - let mut contact_with_vcard = contact; - contact_with_vcard.vcard = Self::generate_vcard(&contact_with_vcard); + let vcard = Self::generate_vcard(&contact); + contact.set_vcard(vcard); - let created = self.contact_repository.create_contact(contact_with_vcard).await?; + let created = self.contact_repository.create_contact(contact).await?; Ok(ContactDto::from(created)) } @@ -347,28 +345,29 @@ impl ContactUseCase for ContactStorageAdapter { self.check_write_access(&address_book_id, &dto.user_id).await?; // Parse vCard - for now, create a basic contact with the raw vCard - let contact = Contact { - id: Uuid::new_v4(), + let now = chrono::Utc::now(); + let contact = Contact::from_raw( + Uuid::new_v4(), address_book_id, - uid: format!("{}@oxicloud", Uuid::new_v4()), - full_name: Some("Imported Contact".to_string()), - first_name: None, - last_name: None, - nickname: None, - email: Vec::new(), - phone: Vec::new(), - address: Vec::new(), - organization: None, - title: None, - notes: None, - photo_url: None, - birthday: None, - anniversary: None, - vcard: dto.vcard, - etag: Uuid::new_v4().to_string(), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }; + format!("{}@oxicloud", Uuid::new_v4()), + Some("Imported Contact".to_string()), + None, + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + None, + None, + None, + None, + dto.vcard, + Uuid::new_v4().to_string(), + now, + now, + ); let created = self.contact_repository.create_contact(contact).await?; Ok(ContactDto::from(created)) @@ -383,51 +382,52 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; // Check write access to the address book - self.check_write_access(&contact.address_book_id, &update.user_id).await?; + self.check_write_access(contact.address_book_id(), &update.user_id).await?; if let Some(full_name) = update.full_name { - contact.full_name = Some(full_name); + contact.set_full_name(Some(full_name)); } if let Some(first_name) = update.first_name { - contact.first_name = Some(first_name); + contact.set_first_name(Some(first_name)); } if let Some(last_name) = update.last_name { - contact.last_name = Some(last_name); + contact.set_last_name(Some(last_name)); } if let Some(nickname) = update.nickname { - contact.nickname = Some(nickname); + contact.set_nickname(Some(nickname)); } if let Some(emails) = update.email { - contact.email = emails.into_iter().map(Self::dto_to_email).collect(); + contact.set_email(emails.into_iter().map(Self::dto_to_email).collect()); } if let Some(phones) = update.phone { - contact.phone = phones.into_iter().map(Self::dto_to_phone).collect(); + contact.set_phone(phones.into_iter().map(Self::dto_to_phone).collect()); } if let Some(addresses) = update.address { - contact.address = addresses.into_iter().map(Self::dto_to_address).collect(); + contact.set_address(addresses.into_iter().map(Self::dto_to_address).collect()); } if let Some(organization) = update.organization { - contact.organization = Some(organization); + contact.set_organization(Some(organization)); } if let Some(title) = update.title { - contact.title = Some(title); + contact.set_title(Some(title)); } if let Some(notes) = update.notes { - contact.notes = Some(notes); + contact.set_notes(Some(notes)); } if let Some(photo_url) = update.photo_url { - contact.photo_url = Some(photo_url); + contact.set_photo_url(Some(photo_url)); } if let Some(birthday) = update.birthday { - contact.birthday = Some(birthday); + contact.set_birthday(Some(birthday)); } if let Some(anniversary) = update.anniversary { - contact.anniversary = Some(anniversary); + contact.set_anniversary(Some(anniversary)); } - contact.updated_at = chrono::Utc::now(); - contact.etag = Uuid::new_v4().to_string(); - contact.vcard = Self::generate_vcard(&contact); + contact.set_updated_at(chrono::Utc::now()); + contact.set_etag(Uuid::new_v4().to_string()); + let vcard = Self::generate_vcard(&contact); + contact.set_vcard(vcard); let updated = self.contact_repository.update_contact(contact).await?; Ok(ContactDto::from(updated)) @@ -442,7 +442,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; // Check write access - self.check_write_access(&contact.address_book_id, user_id).await?; + self.check_write_access(contact.address_book_id(), user_id).await?; self.contact_repository.delete_contact(&uuid).await } @@ -456,7 +456,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; // Check read access - self.check_address_book_access(&contact.address_book_id, user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id).await?; Ok(ContactDto::from(contact)) } @@ -487,13 +487,10 @@ impl ContactUseCase for ContactStorageAdapter { // Check write access self.check_write_access(&address_book_id, &dto.user_id).await?; - let group = ContactGroup { - id: Uuid::new_v4(), + let group = ContactGroup::new( address_book_id, - name: dto.name, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }; + dto.name, + ); let created = self.group_repository.create_group(group).await?; Ok(ContactGroupDto::from(created)) @@ -508,10 +505,10 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; // Check write access - self.check_write_access(&group.address_book_id, &update.user_id).await?; + self.check_write_access(group.address_book_id(), &update.user_id).await?; - group.name = update.name; - group.updated_at = chrono::Utc::now(); + group.set_name(update.name); + group.set_updated_at(chrono::Utc::now()); let updated = self.group_repository.update_group(group).await?; Ok(ContactGroupDto::from(updated)) @@ -526,7 +523,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; // Check write access - self.check_write_access(&group.address_book_id, user_id).await?; + self.check_write_access(group.address_book_id(), user_id).await?; self.group_repository.delete_group(&uuid).await } @@ -540,7 +537,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; // Check read access - self.check_address_book_access(&group.address_book_id, user_id).await?; + self.check_address_book_access(group.address_book_id(), user_id).await?; Ok(ContactGroupDto::from(group)) } @@ -565,7 +562,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; // Check write access - self.check_write_access(&group.address_book_id, user_id).await?; + self.check_write_access(group.address_book_id(), user_id).await?; self.group_repository.add_contact_to_group(&group_id, &contact_id).await } @@ -580,7 +577,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; // Check write access - self.check_write_access(&group.address_book_id, user_id).await?; + self.check_write_access(group.address_book_id(), user_id).await?; self.group_repository.remove_contact_from_group(&group_id, &contact_id).await } @@ -594,7 +591,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; // Check read access - self.check_address_book_access(&group.address_book_id, user_id).await?; + self.check_address_book_access(group.address_book_id(), user_id).await?; let contacts = self.group_repository.get_contacts_in_group(&uuid).await?; Ok(contacts.into_iter().map(ContactDto::from).collect()) @@ -609,7 +606,7 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; // Check read access - self.check_address_book_access(&contact.address_book_id, user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id).await?; let groups = self.group_repository.get_groups_for_contact(&uuid).await?; Ok(groups.into_iter().map(ContactGroupDto::from).collect()) @@ -624,9 +621,9 @@ impl ContactUseCase for ContactStorageAdapter { .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; // Check read access - self.check_address_book_access(&contact.address_book_id, user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id).await?; - Ok(contact.vcard) + Ok(contact.vcard().to_string()) } async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { @@ -639,7 +636,7 @@ impl ContactUseCase for ContactStorageAdapter { Ok(contacts .into_iter() - .map(|c| (c.id.to_string(), c.vcard)) + .map(|c| (c.id().to_string(), c.vcard().to_string())) .collect()) } } diff --git a/src/common/auth_factory.rs b/src/infrastructure/auth_factory.rs similarity index 100% rename from src/common/auth_factory.rs rename to src/infrastructure/auth_factory.rs diff --git a/src/common/db.rs b/src/infrastructure/db.rs similarity index 100% rename from src/common/db.rs rename to src/infrastructure/db.rs diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index a8330fa3..478a3be2 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,4 +1,6 @@ pub mod adapters; +pub mod auth_factory; +pub mod db; pub mod repositories; pub mod services; diff --git a/src/infrastructure/repositories/composite_file_repository.rs b/src/infrastructure/repositories/composite_file_repository.rs new file mode 100644 index 00000000..a23a2c4f --- /dev/null +++ b/src/infrastructure/repositories/composite_file_repository.rs @@ -0,0 +1,139 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; + +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::services::path_service::StoragePath; + +/// Composite que envuelve `Arc` + `Arc` +/// y delega cada método al port correspondiente. +/// +/// Gracias al blanket impl `impl FileStoragePort for T {}` +/// este tipo obtiene `FileStoragePort` automáticamente. +pub struct CompositeFileRepository { + read: Arc, + write: Arc, +} + +impl CompositeFileRepository { + pub fn new(read: Arc, write: Arc) -> Self { + Self { read, write } + } +} + +// ───────────────────────────────────────────────────── +// FileReadPort — delegate to self.read +// ───────────────────────────────────────────────────── + +#[async_trait] +impl FileReadPort for CompositeFileRepository { + async fn get_file(&self, id: &str) -> Result { + self.read.get_file(id).await + } + + async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { + self.read.list_files(folder_id).await + } + + async fn get_file_content(&self, id: &str) -> Result, DomainError> { + self.read.get_file_content(id).await + } + + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError> { + self.read.get_file_stream(id).await + } + + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError> { + self.read.get_file_range_stream(id, start, end).await + } + + async fn get_file_mmap(&self, id: &str) -> Result { + self.read.get_file_mmap(id).await + } + + async fn get_file_path(&self, id: &str) -> Result { + self.read.get_file_path(id).await + } + + async fn get_parent_folder_id(&self, path: &str) -> Result { + self.read.get_parent_folder_id(path).await + } +} + +// ───────────────────────────────────────────────────── +// FileWritePort — delegate to self.write +// ───────────────────────────────────────────────────── + +#[async_trait] +impl FileWritePort for CompositeFileRepository { + async fn save_file( + &self, + name: String, + folder_id: Option, + content_type: String, + content: Vec, + ) -> Result { + self.write.save_file(name, folder_id, content_type, content).await + } + + async fn save_file_from_stream( + &self, + name: String, + folder_id: Option, + content_type: String, + stream: std::pin::Pin> + Send>>, + ) -> Result { + self.write.save_file_from_stream(name, folder_id, content_type, stream).await + } + + async fn move_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result { + self.write.move_file(file_id, target_folder_id).await + } + + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { + self.write.delete_file(id).await + } + + async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError> { + self.write.update_file_content(file_id, content).await + } + + async fn register_file_deferred( + &self, + name: String, + folder_id: Option, + content_type: String, + size: u64, + ) -> Result<(File, PathBuf), DomainError> { + self.write.register_file_deferred(name, folder_id, content_type, size).await + } + + async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { + self.write.move_to_trash(file_id).await + } + + async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError> { + self.write.restore_from_trash(file_id, original_path).await + } + + async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> { + self.write.delete_file_permanently(file_id).await + } +} diff --git a/src/infrastructure/repositories/file_fs_read_repository.rs b/src/infrastructure/repositories/file_fs_read_repository.rs index d8fde6a5..6d184a81 100644 --- a/src/infrastructure/repositories/file_fs_read_repository.rs +++ b/src/infrastructure/repositories/file_fs_read_repository.rs @@ -1,186 +1,367 @@ use std::path::PathBuf; use std::sync::Arc; + use async_trait::async_trait; +use tokio::{fs, time}; +use tokio::fs::File as TokioFile; +use tokio_util::codec::{BytesCodec, FramedRead}; +use futures::{Stream, StreamExt}; use bytes::Bytes; -use futures::Stream; +use tokio::task; +use mime_guess::from_path; use crate::domain::entities::file::File; use crate::application::ports::storage_ports::FileReadPort; use crate::common::errors::DomainError; -use crate::domain::repositories::file_repository::FileRepositoryResult; -use crate::infrastructure::repositories::file_metadata_manager::{FileMetadataManager, MetadataError}; -use crate::infrastructure::repositories::file_path_resolver::FilePathResolver; -use crate::domain::services::path_service::StoragePath; +use crate::infrastructure::repositories::repository_errors::{FileRepositoryResult, FileRepositoryError}; use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; +use crate::application::ports::cache_ports::MetadataCachePort; +use crate::application::services::storage_mediator::StorageMediator; +use crate::infrastructure::services::path_service::PathService; +use crate::domain::services::path_service::StoragePath; use crate::common::config::AppConfig; -/// Implementación de repositorio para operaciones de lectura de archivos +/// Implementación de repositorio para operaciones de **lectura** de archivos. +/// +/// Implementa `FileReadPort`: +/// get_file, list_files, get_file_content, get_file_stream, +/// get_file_range_stream, get_file_mmap, get_file_path, get_parent_folder_id. pub struct FileFsReadRepository { - metadata_manager: Arc, - path_resolver: Arc, + root_path: PathBuf, + storage_mediator: Arc, + id_mapping_service: Arc, + path_service: Arc, + metadata_cache: Arc, + config: AppConfig, + parallel_processor: Option>, } impl FileFsReadRepository { - /// Crea un nuevo repositorio de lectura de archivos + /// Constructor completo con todas las dependencias de infraestructura. pub fn new( - _root_path: PathBuf, - metadata_manager: Arc, - path_resolver: Arc, - _config: AppConfig, - _parallel_processor: Option>, + root_path: PathBuf, + storage_mediator: Arc, + id_mapping_service: Arc, + path_service: Arc, + metadata_cache: Arc, + config: AppConfig, + parallel_processor: Option>, ) -> Self { Self { - metadata_manager, - path_resolver, + root_path, + storage_mediator, + id_mapping_service, + path_service, + metadata_cache, + config, + parallel_processor, } } - - /// Crea un stub para pruebas + + /// Stub para pruebas (no realiza I/O real). pub fn default_stub() -> Self { Self { - metadata_manager: Arc::new(FileMetadataManager::default()), - path_resolver: Arc::new(FilePathResolver::default_stub()), + root_path: PathBuf::from("./storage"), + storage_mediator: Arc::new( + crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub(), + ), + id_mapping_service: Arc::new(crate::common::stubs::StubIdMappingPort), + path_service: Arc::new(PathService::new(PathBuf::from("./storage"))), + metadata_cache: Arc::new( + crate::infrastructure::services::file_metadata_cache::FileMetadataCache::default() + ) as Arc, + config: AppConfig::default(), + parallel_processor: None, } } - - /// Crea una entidad de archivo a partir de metadatos - async fn create_file_entity( - &self, - id: String, - name: String, - storage_path: StoragePath, - size: u64, - mime_type: String, - folder_id: Option, - created_at: Option, - modified_at: Option, - ) -> FileRepositoryResult { - // If timestamps are provided, use them; otherwise, let File::new create default timestamps - if let (Some(created), Some(modified)) = (created_at, modified_at) { - File::with_timestamps( - id, - name, - storage_path, - size, - mime_type, - folder_id, - created, - modified, - ) - .map_err(|e| crate::domain::repositories::file_repository::FileRepositoryError::Other(e.to_string())) - } else { - File::new( - id, - name, - storage_path, - size, - mime_type, - folder_id, - ) - .map_err(|e| crate::domain::repositories::file_repository::FileRepositoryError::Other(e.to_string())) - } + + // ─── helpers internos ──────────────────────────────────── + + fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { + self.path_service.resolve_path(storage_path) } - - /// Obtiene un archivo por su ID + + async fn get_file_metadata_raw(&self, abs_path: &PathBuf) -> FileRepositoryResult<(u64, u64, u64)> { + // Cache first + if let Some(cached) = self.metadata_cache.get_metadata(abs_path).await { + if let (Some(s), Some(c), Some(m)) = (cached.size, cached.created_at, cached.modified_at) { + return Ok((s, c, m)); + } + } + let metadata = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(abs_path)) + .await + .map_err(|_| FileRepositoryError::StorageError(format!("Timeout metadata: {}", abs_path.display())))? + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; + let size = metadata.len(); + let created_at = metadata.created() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + .unwrap_or(0); + let modified_at = metadata.modified() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + .unwrap_or(0); + let _ = self.metadata_cache.refresh_metadata(abs_path).await; + Ok((size, created_at, modified_at)) + } + async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult { - // Obtener la ruta del archivo usando el resolver de rutas - let storage_path = self.path_resolver.get_path_by_id(id).await?; - - // Verificar que el archivo existe físicamente - let abs_path = self.path_resolver.resolve_storage_path(&storage_path); - if !self.metadata_manager.file_exists(&abs_path).await - .map_err(|e| crate::domain::repositories::file_repository::FileRepositoryError::Other(e.to_string()))? { - return Err(crate::domain::repositories::file_repository::FileRepositoryError::NotFound( - format!("File {} not found at {}", id, storage_path.to_string()) + let storage_path = self.id_mapping_service.get_path_by_id(id).await + .map_err(|e| FileRepositoryError::Other(e.to_string()))?; + let abs_path = self.resolve_storage_path(&storage_path); + + if !abs_path.exists() || !abs_path.is_file() { + return Err(FileRepositoryError::NotFound( + format!("File {} not found at {}", id, storage_path.to_string()), )); } - - // Obtener metadatos del archivo - let (size, created_at, modified_at) = self.metadata_manager.get_file_metadata(&abs_path).await - .map_err(|e| match e { - MetadataError::IoError(io_err) => crate::domain::repositories::file_repository::FileRepositoryError::IoError(io_err), - MetadataError::Timeout(msg) => crate::domain::repositories::file_repository::FileRepositoryError::Timeout(msg), - MetadataError::Unavailable(msg) => crate::domain::repositories::file_repository::FileRepositoryError::NotFound(msg), - })?; - - // Obtener nombre del archivo de la ruta - let name = match storage_path.file_name() { - Some(name) => name, - None => { - return Err(crate::domain::repositories::file_repository::FileRepositoryError::InvalidPath( - storage_path.to_string() - )); - } - }; - - // Determinar ID de carpeta padre - let parent = storage_path.parent(); - let folder_id: Option = if parent.is_none() || parent.as_ref().unwrap().is_empty() { - None // Root folder - } else { - None // En implementación real, buscar ID de la carpeta padre - }; - - // Determinar tipo MIME - let mime_type = mime_guess::from_path(&abs_path) - .first_or_octet_stream() - .to_string(); - - // Crear entidad de archivo - let file = self.create_file_entity( - id.to_string(), - name, - storage_path, - size, - mime_type, - folder_id, - Some(created_at), - Some(modified_at), - ).await?; - - Ok(file) + + let (size, created_at, modified_at) = self.get_file_metadata_raw(&abs_path).await?; + let name = storage_path + .file_name() + .ok_or_else(|| FileRepositoryError::InvalidPath(storage_path.to_string()))?; + let mime_type = from_path(&abs_path).first_or_octet_stream().to_string(); + + File::with_timestamps( + id.to_string(), name, storage_path, size, mime_type, None, + created_at, modified_at, + ) + .map_err(|e| FileRepositoryError::Other(e.to_string())) + } +} + +impl Clone for FileFsReadRepository { + fn clone(&self) -> Self { + Self { + root_path: self.root_path.clone(), + storage_mediator: self.storage_mediator.clone(), + id_mapping_service: self.id_mapping_service.clone(), + path_service: self.path_service.clone(), + metadata_cache: self.metadata_cache.clone(), + config: self.config.clone(), + parallel_processor: self.parallel_processor.clone(), + } } } #[async_trait] impl FileReadPort for FileFsReadRepository { async fn get_file(&self, id: &str) -> Result { - self.get_file_by_id(id).await - .map_err(|e| match e { - crate::domain::repositories::file_repository::FileRepositoryError::NotFound(msg) => DomainError::not_found("File", msg), - crate::domain::repositories::file_repository::FileRepositoryError::IoError(io_err) => DomainError::internal_error("File", io_err.to_string()), - crate::domain::repositories::file_repository::FileRepositoryError::Timeout(msg) => DomainError::internal_error("File", msg), - _ => DomainError::internal_error("File", e.to_string()), - }) + self.get_file_by_id(id).await.map_err(|e| match e { + FileRepositoryError::NotFound(msg) => DomainError::not_found("File", msg), + FileRepositoryError::StorageError(msg) => DomainError::internal_error("File", msg), + other => DomainError::internal_error("File", other.to_string()), + }) } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { - // Implementación real debe obtener la lista de archivos en una carpeta - // Por ahora, devolvemos lista vacía - Ok(Vec::new()) + + async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { + let folder_storage_path = match folder_id { + Some(id) => { + match self.storage_mediator.get_folder_path(id).await { + Ok(path) => { + let lossy = path.to_string_lossy().to_string(); + let folder_name = path.file_name() + .and_then(|f| f.to_str()) + .unwrap_or(&lossy); + StoragePath::from_string(folder_name) + } + Err(_) => return Ok(Vec::new()), + } + } + None => StoragePath::root(), + }; + + let abs_folder_path = self.path_service.resolve_path(&folder_storage_path); + if !abs_folder_path.exists() || !abs_folder_path.is_dir() { + return Ok(Vec::new()); + } + + let mut files_result = Vec::new(); + let mut entries = fs::read_dir(&abs_folder_path).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| DomainError::internal_error("File", e.to_string()))? + { + let path = entry.path(); + if !path.is_file() { continue; } + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with('.') || file_name == "folder_ids.json" || file_name == "file_ids.json" { + continue; + } + let metadata = match fs::metadata(&path).await { + Ok(m) => m, + Err(_) => continue, + }; + let file_storage_path = folder_storage_path.join(&file_name); + let id = match self.id_mapping_service.get_or_create_id(&file_storage_path).await { + Ok(id) => id, + Err(_) => continue, + }; + let size = metadata.len(); + let created_at = metadata.created() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + .unwrap_or(0); + let modified_at = metadata.modified() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + .unwrap_or(0); + let mime_type = from_path(&path).first_or_octet_stream().to_string(); + + match File::with_timestamps(id, file_name, file_storage_path, size, mime_type, folder_id.map(String::from), created_at, modified_at) { + Ok(file) => files_result.push(file), + Err(_) => continue, + } + } + + // Persist any new ID mappings + let _ = self.id_mapping_service.save_changes().await; + Ok(files_result) } - + async fn get_file_content(&self, id: &str) -> Result, DomainError> { - // Primero obtenemos el archivo para verificar existencia let file = self.get_file_by_id(id).await - .map_err(|e| match e { - crate::domain::repositories::file_repository::FileRepositoryError::NotFound(msg) => DomainError::not_found("File", msg), - crate::domain::repositories::file_repository::FileRepositoryError::IoError(io_err) => DomainError::internal_error("File", io_err.to_string()), - crate::domain::repositories::file_repository::FileRepositoryError::Timeout(msg) => DomainError::internal_error("File", msg), - _ => DomainError::internal_error("File", e.to_string()), - })?; - - // Ruta absoluta del archivo - let _abs_path = self.path_resolver.resolve_storage_path(file.storage_path()); - - // Implementación real debe leer el contenido del archivo - // Por ahora, devolvemos un vector vacío - Ok(Vec::new()) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let abs_path = self.resolve_storage_path(file.storage_path()); + + let metadata = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", format!("Timeout metadata: {}", abs_path.display())))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let file_size = metadata.len(); + + if !self.config.resources.can_load_in_memory(file_size) { + return Err(DomainError::internal_error("File", + format!("File too large for memory: {} MB", file_size / (1024 * 1024)))); + } + + // Parallel read for very large files + if self.config.resources.needs_parallel_processing(file_size, &self.config.concurrency) { + let content = if let Some(processor) = &self.parallel_processor { + processor.read_file_parallel(&abs_path).await + } else { + let processor = ParallelFileProcessor::new(self.config.clone()); + processor.read_file_parallel(&abs_path).await + }; + return content.map_err(|e| DomainError::internal_error("File", e.to_string())); + } + + // spawn_blocking for large-ish files + if self.config.resources.is_large_file(file_size) { + let abs_clone = abs_path.clone(); + let chunk_size = self.config.resources.chunk_size_bytes; + let content = task::spawn_blocking(move || -> std::io::Result> { + use std::io::{Read, BufReader}; + let file = std::fs::File::open(&abs_clone)?; + let mut reader = BufReader::with_capacity(chunk_size, file); + let mut buf = Vec::with_capacity(file_size as usize); + reader.read_to_end(&mut buf)?; + Ok(buf) + }).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + return Ok(content); + } + + // Small files — async read + time::timeout(self.config.timeouts.file_timeout(), fs::read(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", format!("Timeout reading: {}", abs_path.display())))? + .map_err(|e| DomainError::internal_error("File", e.to_string())) } - - async fn get_file_stream(&self, _id: &str) -> Result> + Send>, DomainError> { - // Implementación real debe devolver un stream de bytes del archivo - // Por ahora, lanzamos un error - Err(DomainError::internal_error("File stream", "Stream functionality not yet implemented")) + + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError> { + let file = self.get_file_by_id(id).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let abs_path = self.resolve_storage_path(file.storage_path()); + + let metadata = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout getting metadata"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let file_size = metadata.len(); + let is_large = self.config.resources.is_large_file(file_size); + + let fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::open(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout opening file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + let chunk_size = if is_large { self.config.resources.chunk_size_bytes } else { 4096 }; + let codec = BytesCodec::new(); + let stream = FramedRead::with_capacity(fh, codec, chunk_size) + .map(|r| r.map(|bm| bm.freeze())); + Ok(Box::new(stream)) + } + + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError> { + use tokio::io::AsyncSeekExt; + + let file = self.get_file_by_id(id).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let abs_path = self.resolve_storage_path(file.storage_path()); + + let metadata = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let file_size = metadata.len(); + if start >= file_size { + return Err(DomainError::internal_error("File", + format!("Range start {} beyond file size {}", start, file_size))); + } + let actual_end = end.map(|e| e.min(file_size - 1)).unwrap_or(file_size - 1); + let range_length = actual_end - start + 1; + + let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::open(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout opening file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.seek(std::io::SeekFrom::Start(start)).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + let chunk_size = if range_length > 1024 * 1024 { self.config.resources.chunk_size_bytes } else { 8192 }; + use tokio::io::AsyncReadExt; + let limited = fh.take(range_length); + let codec = BytesCodec::new(); + let stream = FramedRead::with_capacity(limited, codec, chunk_size) + .map(|r| r.map(|bm| bm.freeze())); + Ok(Box::new(stream)) + } + + async fn get_file_mmap(&self, id: &str) -> Result { + use memmap2::Mmap; + let file = self.get_file_by_id(id).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let abs_path = self.resolve_storage_path(file.storage_path()); + let path_clone = abs_path.clone(); + + task::spawn_blocking(move || -> Result { + let fh = std::fs::File::open(&path_clone) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let mmap = unsafe { Mmap::map(&fh) } + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + Ok(Bytes::copy_from_slice(&mmap[..])) + }).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))? + } + + async fn get_file_path(&self, id: &str) -> Result { + self.id_mapping_service.get_path_by_id(id).await + } + + async fn get_parent_folder_id(&self, path: &str) -> Result { + let storage_path = StoragePath::from_string(path); + match storage_path.parent() { + Some(parent) if !parent.is_empty() => { + self.id_mapping_service.get_or_create_id(&parent).await + } + _ => Ok("root".to_string()), + } } } \ No newline at end of file diff --git a/src/infrastructure/repositories/file_fs_repository.rs b/src/infrastructure/repositories/file_fs_repository.rs deleted file mode 100644 index 0f4c724d..00000000 --- a/src/infrastructure/repositories/file_fs_repository.rs +++ /dev/null @@ -1,2027 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use async_trait::async_trait; -use tokio::{fs, io::AsyncWriteExt, time}; -use tokio::fs::File as TokioFile; -use tokio_util::codec::{BytesCodec, FramedRead}; -use tracing::instrument; -use mime_guess::from_path; -use futures::{Stream, StreamExt}; -use bytes::Bytes; -use uuid::Uuid; -use tokio::task; - -use crate::infrastructure::services::file_system_utils::FileSystemUtils; - -use crate::domain::entities::file::File; -use crate::domain::repositories::file_repository::{ - FileRepository, FileRepositoryError, FileRepositoryResult -}; -use crate::application::services::storage_mediator::StorageMediator; -// use crate::application::ports::outbound::IdMappingPort; -use crate::infrastructure::services::id_mapping_service::IdMappingError; -use crate::infrastructure::services::file_metadata_cache::{FileMetadataCache, CacheEntryType}; -use crate::domain::services::path_service::StoragePath; -use crate::infrastructure::services::path_service::PathService; -use crate::common::errors::DomainError; -use crate::common::config::AppConfig; -use crate::application::ports::outbound::FileStoragePort; -use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; - -/** - * Filesystem implementation of the File Repository interface. - * - * This repository provides a concrete implementation of the FileRepository domain interface - * that interacts with a filesystem-based storage backend. It implements: - * - * 1. File creation, retrieval, and deletion operations - * 2. File content reading (both in-memory and streaming) - * 3. Folder organization for files - * 4. ID-to-path mapping persistence - * 5. Optimized handling of large files using parallel I/O - * 6. Metadata caching to reduce filesystem operations - * 7. Trash operations for file lifecycle management - * - * The implementation follows the hexagonal architecture pattern as a secondary adapter, - * implementing domain interfaces and ports while isolating the application core from - * filesystem-specific details. - */ - -// Use constants from centralized configuration instead of fixed values -// This is replaced with self.config.concurrency.max_concurrent_files later - -/// Filesystem implementation of the FileRepository interface -pub struct FileFsRepository { - root_path: PathBuf, - storage_mediator: Arc, - id_mapping_service: Arc, - path_service: Arc, - metadata_cache: Arc, - config: AppConfig, - parallel_processor: Option>, -} - -impl FileFsRepository { - /// Creates a new filesystem-based file repository - pub fn new( - root_path: PathBuf, - storage_mediator: Arc, - id_mapping_service: Arc, - path_service: Arc, - metadata_cache: Arc, - ) -> Self { - Self { - root_path, - storage_mediator, - id_mapping_service, - path_service, - metadata_cache, - config: AppConfig::default(), - parallel_processor: None, - } - } - - /// Creates a new repository with a pre-configured parallel file processor - pub fn new_with_processor( - root_path: PathBuf, - storage_mediator: Arc, - id_mapping_service: Arc, - path_service: Arc, - metadata_cache: Arc, - parallel_processor: Arc, - ) -> Self { - Self { - root_path, - storage_mediator, - id_mapping_service, - path_service, - metadata_cache, - config: AppConfig::default(), - parallel_processor: Some(parallel_processor), - } - } - - /// Resolves a domain storage path to an absolute filesystem path - fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { - self.path_service.resolve_path(storage_path) - } - - /// Resolves a legacy PathBuf to an absolute filesystem path - #[allow(dead_code)] - fn resolve_legacy_path(&self, relative_path: &std::path::Path) -> PathBuf { - self.storage_mediator.resolve_path(relative_path) - } - - /// Returns a reference to the ID mapping service - pub fn id_mapping_service(&self) -> &Arc { - &self.id_mapping_service - } - - /// Returns a reference to the metadata cache - pub fn metadata_cache(&self) -> &Arc { - &self.metadata_cache - } - - /// Returns a reference to the root path - pub fn get_root_path(&self) -> &PathBuf { - &self.root_path - } - - /// Checks if a file exists at a given storage path - async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> FileRepositoryResult { - let abs_path = self.resolve_storage_path(storage_path); - - // Try to get from advanced cache first - if let Some(is_file) = self.metadata_cache.is_file(&abs_path).await { - tracing::debug!("Metadata cache hit for existence check: {} - path: {}", is_file, abs_path.display()); - return Ok(is_file); - } - - // If not in cache, verify directly and update cache - tracing::debug!("Metadata cache miss for existence check: {}", abs_path.display()); - - // Use timeout to avoid blocking - match time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await { - Ok(Ok(metadata)) => { - let is_file = metadata.is_file(); - - // Update cache with fresh information - if let Err(e) = self.metadata_cache.refresh_metadata(&abs_path).await { - tracing::warn!("Failed to update cache for {}: {}", abs_path.display(), e); - } - - if is_file { - tracing::debug!("File exists and is accessible: {}", abs_path.display()); - Ok(true) - } else { - tracing::warn!("Path exists but is not a file: {}", abs_path.display()); - Ok(false) - } - }, - Ok(Err(e)) => { - tracing::warn!("File check failed: {} - {}", abs_path.display(), e); - - // Add to cache as non-existent - let entry_type = CacheEntryType::Unknown; - let file_metadata = crate::infrastructure::services::file_metadata_cache::FileMetadata::new( - abs_path.clone(), - false, - entry_type, - None, - None, - None, - None, - Duration::from_millis(self.config.timeouts.file_operation_ms), - ); - self.metadata_cache.update_cache(file_metadata).await; - - Ok(false) - }, - Err(_) => { - tracing::warn!("Timeout checking file metadata: {}", abs_path.display()); - return Err(FileRepositoryError::Timeout(format!("Timeout checking file: {}", abs_path.display()))); - } - } - } - - /// Legacy method for checking file existence with PathBuf - #[allow(dead_code)] - pub async fn file_exists(&self, path: &std::path::Path) -> FileRepositoryResult { - let abs_path = self.resolve_legacy_path(path); - - // Try to get from advanced cache first - if let Some(is_file) = self.metadata_cache.is_file(&abs_path).await { - tracing::debug!("Metadata cache hit for legacy existence check: {} - path: {}", is_file, abs_path.display()); - return Ok(is_file); - } - - // If not in cache, verify directly - tracing::info!("Checking if file exists: {} - path: {}", abs_path.exists(), abs_path.display()); - - match time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await { - Ok(Ok(metadata)) => { - let is_file = metadata.is_file(); - - // Update cache with fresh information - if let Err(e) = self.metadata_cache.refresh_metadata(&abs_path).await { - tracing::warn!("Failed to update cache for {}: {}", abs_path.display(), e); - } - - if is_file { - tracing::info!("File exists and is accessible: {}", abs_path.display()); - return Ok(true); - } else { - tracing::warn!("Path exists but is not a file: {}", abs_path.display()); - return Ok(false); - } - }, - Ok(Err(e)) => { - tracing::warn!("File exists but metadata check failed: {} - {}", abs_path.display(), e); - return Ok(false); - }, - Err(_) => { - tracing::warn!("Timeout checking file metadata: {}", abs_path.display()); - return Err(FileRepositoryError::Timeout(format!("Timeout checking file: {}", abs_path.display()))); - } - } - } - - /// Helper method to create a File entity from a storage path and metadata - async fn create_file_entity( - &self, - id: String, - name: String, - storage_path: StoragePath, - size: u64, - mime_type: String, - folder_id: Option, - created_at: Option, - modified_at: Option, - ) -> FileRepositoryResult { - // If timestamps are provided, use them; otherwise, let File::new create default timestamps - if let (Some(created), Some(modified)) = (created_at, modified_at) { - File::with_timestamps( - id, - name, - storage_path, - size, - mime_type, - folder_id, - created, - modified, - ) - .map_err(|e| FileRepositoryError::Other(e.to_string())) - } else { - File::new( - id, - name, - storage_path, - size, - mime_type, - folder_id, - ) - .map_err(|e| FileRepositoryError::Other(e.to_string())) - } - } - - /// Extracts file metadata from a physical path with timeout and cache - async fn get_file_metadata(&self, abs_path: &PathBuf) -> FileRepositoryResult<(u64, u64, u64)> { - // Try to get from cache first - if let Some(cached_metadata) = self.metadata_cache.get_metadata(abs_path).await { - if let (Some(size), Some(created_at), Some(modified_at)) = - (cached_metadata.size, cached_metadata.created_at, cached_metadata.modified_at) { - tracing::debug!("Using cached metadata for: {}", abs_path.display()); - return Ok((size, created_at, modified_at)); - } - } - - // If not in cache or incomplete metadata, load from filesystem - let metadata = match time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await { - Ok(Ok(metadata)) => metadata, - Ok(Err(e)) => return Err(FileRepositoryError::IoError(e)), - Err(_) => return Err(FileRepositoryError::Timeout( - format!("Timeout getting metadata for: {}", abs_path.display()) - )), - }; - - let size = metadata.len(); - - // Get creation timestamp - let created_at = metadata.created() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or_else(|_| 0); - - // Get modification timestamp - let modified_at = metadata.modified() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or_else(|_| 0); - - // Update cache if possible - if let Err(e) = self.metadata_cache.refresh_metadata(abs_path).await { - tracing::warn!("Failed to update metadata cache for {}: {}", abs_path.display(), e); - } - - Ok((size, created_at, modified_at)) - } - - /// Creates parent directories if needed with timeout and fsync - async fn ensure_parent_directory(&self, abs_path: &PathBuf) -> FileRepositoryResult<()> { - if let Some(parent) = abs_path.parent() { - time::timeout( - self.config.timeouts.dir_timeout(), - FileSystemUtils::create_dir_with_sync(parent) - ).await - .map_err(|_| FileRepositoryError::Timeout( - format!("Timeout creating parent directory: {}", parent.display()) - ))? - .map_err(FileRepositoryError::IoError)?; - } - Ok(()) - } - - /// Check if a file is large based on size threshold from config - async fn is_large_file(&self, abs_path: &PathBuf) -> FileRepositoryResult { - if !abs_path.exists() { - return Ok(false); - } - - let metadata = time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout checking file size: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - // Use the ResourceConfig method to determine if it's a large file - Ok(self.config.resources.is_large_file(metadata.len())) - } - - /// Non-blocking file deletion for large files - async fn delete_file_non_blocking(&self, abs_path: PathBuf) -> FileRepositoryResult<()> { - // Check if file is large enough to warrant spawn_blocking - let is_large = self.is_large_file(&abs_path).await?; - - if is_large { - tracing::info!("Using non-blocking deletion for large file: {}", abs_path.display()); - - // Use spawn_blocking for large files to prevent blocking the runtime - task::spawn_blocking(move || { - // Use standard library's blocking remove_file - match std::fs::remove_file(&abs_path) { - Ok(_) => tracing::info!("Successfully deleted large file: {}", abs_path.display()), - Err(e) => tracing::error!("Failed to delete large file: {} - {}", abs_path.display(), e), - } - }).await - .map_err(|e| FileRepositoryError::Other(format!("Join error in spawn_blocking: {}", e)))?; - } else { - // For smaller files use tokio's async version - time::timeout( - self.config.timeouts.file_timeout(), - fs::remove_file(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout deleting file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - } - - Ok(()) - } -} - -// Convert IdMappingError to FileRepositoryError -impl From for FileRepositoryError { - fn from(err: IdMappingError) -> Self { - match err { - IdMappingError::NotFound(id) => FileRepositoryError::NotFound(id), - IdMappingError::IoError(e) => FileRepositoryError::IoError(e), - IdMappingError::Timeout(msg) => FileRepositoryError::Timeout(msg), - _ => FileRepositoryError::Other(err.to_string()), - } - } -} - -// Errors are already defined by the FileRepositoryError interface - -// Enable cloning for concurrent operations -impl Clone for FileFsRepository { - fn clone(&self) -> Self { - Self { - root_path: self.root_path.clone(), - storage_mediator: self.storage_mediator.clone(), - id_mapping_service: self.id_mapping_service.clone(), - path_service: self.path_service.clone(), - metadata_cache: self.metadata_cache.clone(), - config: self.config.clone(), - parallel_processor: self.parallel_processor.clone(), - } - } -} - -#[async_trait] -impl FileStoragePort for FileFsRepository { - async fn save_file( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> Result { - self.save_file_from_bytes(name, folder_id, content_type, content) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to save file: {}", e))) - } - - async fn save_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - stream: std::pin::Pin> + Send>>, - ) -> Result { - FileRepository::save_file_from_stream(self, name, folder_id, content_type, stream) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to save file from stream: {}", e))) - } - - async fn get_file(&self, id: &str) -> Result { - self.get_file_by_id(id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get file with ID: {}: {}", id, e))) - } - - async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { - FileRepository::list_files(self, folder_id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to list files in folder: {:?}: {}", folder_id, e))) - } - - async fn delete_file(&self, id: &str) -> Result<(), DomainError> { - FileRepository::delete_file(self, id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to delete file with ID: {}: {}", id, e))) - } - - async fn get_file_content(&self, id: &str) -> Result, DomainError> { - FileRepository::get_file_content(self, id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get content for file with ID: {}: {}", id, e))) - } - - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError> { - FileRepository::get_file_stream(self, id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get stream for file with ID: {}: {}", id, e))) - } - - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option - ) -> Result> + Send>, DomainError> { - FileRepository::get_file_range_stream(self, id, start, end) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get range stream for file with ID: {}: {}", id, e))) - } - - async fn get_file_mmap(&self, id: &str) -> Result { - FileRepository::get_file_mmap(self, id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to mmap file with ID: {}: {}", id, e))) - } - - async fn move_file(&self, file_id: &str, target_folder_id: Option) -> Result { - // Clone target_folder_id before passing to avoid ownership issues - let cloned_target = target_folder_id.clone(); - let result = FileRepository::move_file(self, file_id, target_folder_id) - .await; - - result.map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to move file with ID: {} to folder: {:?}: {}", file_id, cloned_target, e))) - } - - async fn get_file_path(&self, id: &str) -> Result { - FileRepository::get_file_path(self, id) - .await - .map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get path for file with ID: {}: {}", id, e))) - } - - async fn get_parent_folder_id(&self, path: &str) -> Result { - // Convert path string to StoragePath - let storage_path = StoragePath::from_string(path); - - // Get parent path - let parent_path = match storage_path.parent() { - Some(parent) => parent, - None => return Ok("root".to_string()), // Root folder - }; - - // If it's an empty path (root), return root ID - if parent_path.is_empty() { - return Ok("root".to_string()); - } - - // Try to get the ID for the parent path from the ID mapping service - let parent_id = self.id_mapping_service.get_or_create_id(&parent_path).await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to get parent folder ID for path: {}: {}", path, e)))?; - - Ok(parent_id) - } - - async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError> { - // First get the file to make sure it exists and to get its path - let file = self.get_file_by_id(file_id).await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to get file for update: {}: {}", file_id, e)))?; - - // Get the file path for writing - let file_path = FileStoragePort::get_file_path(self, file_id).await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to get file path for update: {}: {}", file_id, e)))?; - - // Resolve to actual filesystem path - let physical_path = self.storage_mediator.resolve_storage_path(&file_path); - - // Write the content to the file with fsync - FileSystemUtils::atomic_write(&physical_path, &content) - .await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to write updated content to file: {}: {}", file_id, e)))?; - - // Get the metadata and add it to cache if available - if let Some(metadata) = std::fs::metadata(&physical_path).ok() { - // Create a FileMetadata instance and update the cache - use crate::infrastructure::services::file_metadata_cache::FileMetadata; - use crate::infrastructure::services::file_metadata_cache::CacheEntryType; - use std::time::UNIX_EPOCH; - use std::time::Duration; - - // Get modified and created times - let created_at = metadata.created() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let modified_at = metadata.modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - // Default TTL - let ttl = Duration::from_secs(60); // 1 minute - - // Create FileMetadata instance - let file_metadata = FileMetadata::new( - physical_path.clone(), - true, // exists - CacheEntryType::File, - Some(metadata.len()), - Some(file.mime_type().to_string()), - created_at, - modified_at, - ttl, - ); - - // Update the cache - self.metadata_cache.update_cache(file_metadata).await; - } - - Ok(()) - } - - /// Registra metadatos de archivo SIN escribir contenido (write-behind puro) - /// - /// Este método es ultrarrápido (~0.1ms) porque: - /// 1. NO escribe a disco - /// 2. Solo genera ID y registra mappings - /// 3. Devuelve la ruta donde DEBE escribirse el contenido - async fn register_file_deferred( - &self, - name: String, - folder_id: Option, - content_type: String, - size: u64, - ) -> Result<(File, PathBuf), DomainError> { - use std::time::{SystemTime, UNIX_EPOCH}; - use mime_guess::from_path; - - // Get the folder path from the mediator - let folder_path = match &folder_id { - Some(id) => { - match self.storage_mediator.get_folder_path(id).await { - Ok(path) => { - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or_else(|| &lossy); - StoragePath::from_string(folder_name) - }, - Err(_) => StoragePath::root(), - } - }, - None => StoragePath::root(), - }; - - // Create the storage path for the file - let mut file_storage_path = folder_path.join(&name); - let mut original_name = name.clone(); - - // Check for duplicates and generate unique name - let mut counter = 1; - while self.file_exists_at_storage_path(&file_storage_path).await.unwrap_or(false) { - let (stem, ext) = if let Some(dot_pos) = original_name.rfind('.') { - (original_name[..dot_pos].to_string(), original_name[dot_pos..].to_string()) - } else { - (original_name.clone(), String::new()) - }; - let new_name = format!("{}_{}{}", stem, counter, ext); - file_storage_path = folder_path.join(&new_name); - original_name = new_name; - counter += 1; - } - - // Resolve absolute path (this is where content will be written) - let abs_path = self.resolve_storage_path(&file_storage_path); - - // Ensure parent directory exists - self.ensure_parent_directory(&abs_path).await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to create parent directory: {}", e)))?; - - // Determine MIME type - let mime_type = if content_type.is_empty() { - from_path(&abs_path).first_or_octet_stream().to_string() - } else { - content_type - }; - - // Get current timestamp - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // Generate unique ID and register mapping - let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to generate file ID: {}", e)))?; - - // Save ID mapping - self.id_mapping_service.save_changes().await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to save ID mapping: {}", e)))?; - - // Create File entity (with provided size, timestamps are "now") - let file = self.create_file_entity( - id.clone(), - original_name, - file_storage_path, - size, - mime_type, - folder_id, - Some(now), - Some(now), - ).await - .map_err(|e| DomainError::internal_error("FileStorage", - format!("Failed to create file entity: {}", e)))?; - - tracing::debug!("⚡ Registered deferred file: {} -> {:?}", id, abs_path); - - Ok((file, abs_path)) - } -} - -#[async_trait] -impl FileRepository for FileFsRepository { - #[instrument(skip(self))] - async fn move_to_trash(&self, file_id: &str) -> FileRepositoryResult<()> { - tracing::info!("FileRepository::move_to_trash called for file ID: {}", file_id); - // Call the internal implementation for trash handling - match self._trash_move_to_trash(file_id).await { - Ok(_) => { - tracing::info!("File successfully moved to trash: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to move file to trash: {} - {}", file_id, e); - Err(e) - } - } - } - - #[instrument(skip(self))] - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> FileRepositoryResult<()> { - tracing::info!("FileRepository::restore_from_trash called for file ID: {} to path: {}", file_id, original_path); - match self._trash_restore_from_trash(file_id, original_path).await { - Ok(_) => { - tracing::info!("File successfully restored from trash: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to restore file from trash: {} - {}", file_id, e); - Err(e) - } - } - } - - #[instrument(skip(self))] - async fn delete_file_permanently(&self, file_id: &str) -> FileRepositoryResult<()> { - tracing::info!("FileRepository::delete_file_permanently called for file ID: {}", file_id); - match self._trash_delete_file_permanently(file_id).await { - Ok(_) => { - tracing::info!("File permanently deleted successfully: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to delete file permanently: {} - {}", file_id, e); - Err(e) - } - } - } - - #[instrument(skip(self, content))] - async fn update_file_content(&self, file_id: &str, content: Vec) -> FileRepositoryResult<()> { - tracing::info!("FileRepository::update_file_content called for file ID: {}", file_id); - - // Get the file info to verify it exists and get its path - let file = self.get_file_by_id(file_id).await?; - - // Get the file path - let storage_path = FileRepository::get_file_path(self, file_id).await?; - let physical_path = self.path_service.resolve_path(&storage_path); - - // Write the content to the file with fsync - FileSystemUtils::atomic_write(&physical_path, &content) - .await - .map_err(|e| FileRepositoryError::IoError(e))?; - - // Get the metadata and add it to cache if available - if let Some(metadata) = std::fs::metadata(&physical_path).ok() { - // Create a FileMetadata instance and update the cache - use crate::infrastructure::services::file_metadata_cache::FileMetadata; - use crate::infrastructure::services::file_metadata_cache::CacheEntryType; - use std::time::UNIX_EPOCH; - use std::time::Duration; - - // Get modified and created times - let created_at = metadata.created() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let modified_at = metadata.modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - // Default TTL - let ttl = Duration::from_secs(60); // 1 minute - - // Create FileMetadata instance - let file_metadata = FileMetadata::new( - physical_path.clone(), - true, // exists - CacheEntryType::File, - Some(metadata.len()), - Some(file.mime_type().to_string()), - created_at, - modified_at, - ttl, - ); - - // Update the cache - self.metadata_cache.update_cache(file_metadata).await; - } - - tracing::info!("File content updated successfully: {}", file_id); - Ok(()) - } - async fn save_file_from_bytes( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> FileRepositoryResult - { - // Get the folder path from the mediator - let folder_path = match &folder_id { - Some(id) => { - match self.storage_mediator.get_folder_path(id).await { - Ok(path) => { - tracing::info!("Using folder path: {:?} for folder_id: {:?}", path, id); - // Convert to StoragePath - use just the folder name to avoid path duplication - // Get just the folder name to avoid path duplication - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or_else(|| &lossy); - tracing::info!("Using folder name: {} for StoragePath", folder_name); - StoragePath::from_string(folder_name) - }, - Err(e) => { - tracing::error!("Error getting folder: {}", e); - // Root path - StoragePath::root() - }, - } - }, - None => StoragePath::root(), - }; - - // Create the storage path for the file - let mut file_storage_path = folder_path.join(&name); - tracing::info!("Created file path: {:?}", file_storage_path.to_string()); - - // Check if file already exists and generate a unique name if needed - let mut exists = self.file_exists_at_storage_path(&file_storage_path).await?; - tracing::info!("File exists check: {} for path: {:?}", exists, file_storage_path.to_string()); - - // If file exists, generate a unique name by adding a suffix - let mut original_name = name.clone(); - let mut counter = 1; - - while exists { - // Extract filename and extension - let file_stem; - let extension; - - if let Some(dot_pos) = original_name.rfind('.') { - file_stem = original_name[..dot_pos].to_string(); - extension = original_name[dot_pos..].to_string(); - } else { - file_stem = original_name.clone(); - extension = "".to_string(); - } - - // Create new name with counter - let new_name = format!("{}_{}{}", file_stem, counter, extension); - - // Update the storage path with the new name - let new_file_storage_path = folder_path.join(&new_name); - - // Check if the new path exists - exists = self.file_exists_at_storage_path(&new_file_storage_path).await?; - - if !exists { - // Update variables for the new path - tracing::info!("Generated unique name for duplicate file: {} -> {}", original_name, new_name); - original_name = new_name.clone(); - file_storage_path = new_file_storage_path; - } else { - // Try next counter - counter += 1; - } - } - - // Create parent directories if they don't exist - let abs_path = self.resolve_storage_path(&file_storage_path); - self.ensure_parent_directory(&abs_path).await?; - - // Calculate file size - let content_size = content.len() as u64; - - // Verificar si el archivo es muy grande para el procesamiento paralelo de escritura - if self.config.resources.needs_parallel_processing(content_size, &self.config.concurrency) { - // Para archivos muy grandes, usar procesador paralelo - tracing::info!("Using parallel file processor for large file write: {} ({} bytes)", - abs_path.display(), content_size); - - // Usar el procesador pre-configurado si está disponible o crear uno nuevo - let result = if let Some(processor) = &self.parallel_processor { - tracing::debug!("Using pre-configured parallel processor with buffer pool"); - processor.write_file_parallel(&abs_path, &content).await - } else { - tracing::debug!("Creating on-demand parallel processor"); - // Importar y crear el procesador paralelo - use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; - let processor = ParallelFileProcessor::new(self.config.clone()); - - // Escribir archivo en paralelo - processor.write_file_parallel(&abs_path, &content).await - }; - - // Manejar resultado - result?; - - tracing::info!("Successfully wrote {}MB file using parallel chunks", content_size / (1024 * 1024)); - } else if content_size > self.config.resources.large_file_threshold_mb * 1024 * 1024 { - // Para archivos grandes pero no tanto como para paralelizar, usar chunking - let file_creation_result = time::timeout( - self.config.timeouts.file_timeout(), - TokioFile::create(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout creating file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - let mut file = file_creation_result; - - // Define el tamaño del chunk usando la configuración - let chunk_size = self.config.resources.chunk_size_bytes; - - tracing::info!("Using chunked writing with size {} bytes for file: {} ({} bytes)", - chunk_size, abs_path.display(), content_size); - - // Divide el contenido en chunks y escribe cada uno con timeout - for (i, chunk) in content.chunks(chunk_size).enumerate() { - let _write_result = time::timeout( - self.config.timeouts.file_timeout(), - file.write_all(chunk) - ).await - .map_err(|_| FileRepositoryError::Timeout( - format!("Timeout writing chunk {} to file: {}", i, abs_path.display()) - ))? - .map_err(FileRepositoryError::IoError)?; - - tracing::debug!("Written chunk {} ({} bytes) to file {}", i, chunk.len(), abs_path.display()); - } - - // Ensure file is properly flushed and closed - let _flush_result = time::timeout( - self.config.timeouts.file_timeout(), - file.flush() - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout flushing file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - } else { - // Para archivos pequeños, escritura simple - let file_creation_result = time::timeout( - self.config.timeouts.file_timeout(), - TokioFile::create(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout creating file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - let mut file = file_creation_result; - - // Para archivos pequeños, escribe todo el contenido de una vez - let _write_result = time::timeout( - self.config.timeouts.file_timeout(), - file.write_all(&content) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout writing to file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - // Ensure file is properly flushed and closed - let _flush_result = time::timeout( - self.config.timeouts.file_timeout(), - file.flush() - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout flushing file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - } - - // Get file metadata - let (size, created_at, modified_at) = self.get_file_metadata(&abs_path).await?; - - // Determine the MIME type - let mime_type = if content_type.is_empty() { - from_path(&abs_path) - .first_or_octet_stream() - .to_string() - } else { - content_type - }; - - // Create and return the file entity with a persistent ID - let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await?; - - // Keep a string representation of the path for logging - let path_string = file_storage_path.to_string(); - - let file = self.create_file_entity( - id.clone(), // Clone ID for use in logging - original_name, // Use the potentially modified name with counter suffix - file_storage_path, - size, - mime_type, - folder_id, - Some(created_at), - Some(modified_at), - ).await?; - - // Ensure ID mapping is persisted - this is critical for later retrieval - // Ejecutar múltiples intentos de guardado con verificación para garantizar persistencia - for attempt in 1..=3 { - match self.id_mapping_service.save_changes().await { - Ok(_) => { - tracing::info!("Successfully saved ID mapping for file ID: {} -> path: {} (attempt {})", id, path_string, attempt); - - // Verificar que el mapeo se puede recuperar después de guardado - if let Ok(verified_path) = self.id_mapping_service.get_path_by_id(&id).await { - if verified_path.to_string() == path_string { - tracing::info!("Verified ID mapping is retrievable after save: {} -> {}", id, path_string); - break; // Guaradado correcto y verificado, salir del bucle - } else { - tracing::error!("Mapping verification failed: expected {} but got {}", path_string, verified_path.to_string()); - if attempt < 3 { - tracing::info!("Will retry saving ID mapping (attempt {}/3)", attempt + 1); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - continue; - } else { - return Err(FileRepositoryError::Other( - format!("Failed to verify ID mapping for file: {} after 3 attempts", id) - )); - } - } - } else { - tracing::error!("Cannot verify mapping, ID {} not found after save", id); - if attempt < 3 { - tracing::info!("Will retry saving ID mapping (attempt {}/3)", attempt + 1); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - continue; - } else { - return Err(FileRepositoryError::Other( - format!("Failed to verify ID mapping for file: {} after 3 attempts", id) - )); - } - } - }, - Err(e) => { - tracing::error!("Failed to save ID mapping for file {}: {} (attempt {})", id, e, attempt); - if attempt < 3 { - tracing::info!("Will retry saving ID mapping (attempt {}/3)", attempt + 1); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - continue; - } else { - return Err(FileRepositoryError::Other( - format!("Failed to save ID mapping for file: {} after 3 attempts - {}", id, e) - )); - } - } - } - } - - // Invalidate any directory cache entries for the parent folders - // to ensure directory listings show the new file - if let Some(parent_dir) = abs_path.parent() { - self.metadata_cache.invalidate_directory(parent_dir).await; - } - - tracing::info!("Saved file: {} with ID: {}", path_string, file.id()); - Ok(file) - } - - /// Streaming upload - writes chunks directly to disk as they arrive - /// - /// This is the most efficient method for large file uploads: - /// - Constant ~10MB memory usage regardless of file size - /// - Chunks are written to disk immediately - /// - Uses atomic rename for crash safety - async fn save_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - mut stream: std::pin::Pin> + Send>>, - ) -> FileRepositoryResult { - use tokio::io::AsyncWriteExt; - use futures::StreamExt; - - // Get the folder path from the mediator - let folder_path = match &folder_id { - Some(id) => { - match self.storage_mediator.get_folder_path(id).await { - Ok(path) => { - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or_else(|| &lossy); - StoragePath::from_string(folder_name) - }, - Err(e) => { - tracing::error!("Error getting folder: {}", e); - StoragePath::root() - }, - } - }, - None => StoragePath::root(), - }; - - // Create the storage path for the file - let mut file_storage_path = folder_path.join(&name); - - // Check if file already exists and generate a unique name if needed - let mut exists = self.file_exists_at_storage_path(&file_storage_path).await?; - let mut original_name = name.clone(); - let mut counter = 1; - - while exists { - let file_stem; - let extension; - - if let Some(dot_pos) = original_name.rfind('.') { - file_stem = original_name[..dot_pos].to_string(); - extension = original_name[dot_pos..].to_string(); - } else { - file_stem = original_name.clone(); - extension = "".to_string(); - } - - let new_name = format!("{}_{}{}", file_stem, counter, extension); - let new_file_storage_path = folder_path.join(&new_name); - exists = self.file_exists_at_storage_path(&new_file_storage_path).await?; - - if !exists { - tracing::info!("Generated unique name: {} -> {}", original_name, new_name); - original_name = new_name.clone(); - file_storage_path = new_file_storage_path; - } else { - counter += 1; - } - } - - // Create parent directories if they don't exist - let abs_path = self.resolve_storage_path(&file_storage_path); - self.ensure_parent_directory(&abs_path).await?; - - // Create a temporary file for atomic write - let temp_path = abs_path.with_extension("tmp.upload"); - - tracing::info!("📥 STREAMING UPLOAD: {} -> {}", original_name, abs_path.display()); - - // Create the temp file - let mut file = time::timeout( - self.config.timeouts.file_timeout(), - TokioFile::create(&temp_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout creating temp file: {}", temp_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - // Stream chunks directly to disk - let mut total_bytes: u64 = 0; - let mut chunk_count = 0u32; - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(FileRepositoryError::IoError)?; - let chunk_len = chunk.len(); - - // Write chunk directly to disk - no memory accumulation - file.write_all(&chunk).await.map_err(FileRepositoryError::IoError)?; - - total_bytes += chunk_len as u64; - chunk_count += 1; - - // Log progress every 10MB - if total_bytes > 0 && total_bytes % (10 * 1024 * 1024) < chunk_len as u64 { - tracing::debug!( - "📥 Upload progress: {} - {}MB received ({} chunks)", - original_name, - total_bytes / (1024 * 1024), - chunk_count - ); - } - } - - // Flush and sync to ensure data is on disk - file.flush().await.map_err(FileRepositoryError::IoError)?; - file.sync_all().await.map_err(FileRepositoryError::IoError)?; - drop(file); // Close the file handle - - // Atomic rename from temp to final path - fs::rename(&temp_path, &abs_path).await.map_err(FileRepositoryError::IoError)?; - - tracing::info!( - "✅ STREAMING UPLOAD COMPLETE: {} ({} bytes, {} chunks)", - original_name, total_bytes, chunk_count - ); - - // Get file metadata from disk - let (size, created_at, modified_at) = self.get_file_metadata(&abs_path).await?; - - // Determine the MIME type - let mime_type = if content_type.is_empty() { - from_path(&abs_path).first_or_octet_stream().to_string() - } else { - content_type - }; - - // Create and return the file entity with a persistent ID - let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await?; - let path_string = file_storage_path.to_string(); - - let file = self.create_file_entity( - id.clone(), - original_name, - file_storage_path, - size, - mime_type, - folder_id, - Some(created_at), - Some(modified_at), - ).await?; - - // Persist ID mapping with verification - for attempt in 1..=3 { - match self.id_mapping_service.save_changes().await { - Ok(_) => { - if let Ok(verified_path) = self.id_mapping_service.get_path_by_id(&id).await { - if verified_path.to_string() == path_string { - tracing::debug!("ID mapping verified: {} -> {}", id, path_string); - break; - } - } - if attempt == 3 { - return Err(FileRepositoryError::Other( - format!("Failed to verify ID mapping after 3 attempts") - )); - } - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - }, - Err(e) if attempt < 3 => { - tracing::warn!("ID mapping save failed (attempt {}): {}", attempt, e); - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - }, - Err(e) => { - return Err(FileRepositoryError::Other( - format!("Failed to save ID mapping: {}", e) - )); - } - } - } - - // Invalidate directory cache - if let Some(parent_dir) = abs_path.parent() { - self.metadata_cache.invalidate_directory(parent_dir).await; - } - - Ok(file) - } - - async fn save_file_with_id( - &self, - id: String, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> FileRepositoryResult - { - // Get the folder path from the mediator - let folder_path = match &folder_id { - Some(fid) => { - match self.storage_mediator.get_folder_path(fid).await { - Ok(path) => { - tracing::info!("Using folder path: {:?} for folder_id: {:?}", path, fid); - // Convert to StoragePath - use just the folder name to avoid path duplication - // Get just the folder name to avoid path duplication - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or_else(|| &lossy); - tracing::info!("Using folder name: {} for StoragePath", folder_name); - StoragePath::from_string(folder_name) - }, - Err(e) => { - tracing::error!("Error getting folder: {}", e); - // Root path - StoragePath::root() - }, - } - }, - None => StoragePath::root(), - }; - - // Create the storage path for the file - let file_storage_path = folder_path.join(&name); - tracing::info!("Created file path with ID: {:?} for file: {}", file_storage_path.to_string(), id); - - // Check if file already exists (and handle overwrites if needed) - let exists = self.file_exists_at_storage_path(&file_storage_path).await?; - tracing::info!("File exists check: {} for path: {:?}", exists, file_storage_path.to_string()); - - // For save_file_with_id, force overwrite if needed - let abs_path = self.resolve_storage_path(&file_storage_path); - if exists { - tracing::warn!("File already exists at path: {:?} - will overwrite", file_storage_path.to_string()); - // Delete the existing file with non-blocking approach - self.delete_file_non_blocking(abs_path.clone()).await?; - } - - // Create parent directories if they don't exist - self.ensure_parent_directory(&abs_path).await?; - - // Write the file with timeout - let file_creation_result = time::timeout( - self.config.timeouts.file_timeout(), - TokioFile::create(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout creating file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - let mut file = file_creation_result; - - let _write_result = time::timeout( - self.config.timeouts.file_timeout(), - file.write_all(&content) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout writing to file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - // Ensure file is properly flushed and closed - let _flush_result = time::timeout( - self.config.timeouts.file_timeout(), - file.flush() - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout flushing file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - // Get file metadata - let (size, created_at, modified_at) = self.get_file_metadata(&abs_path).await?; - - // Determine the MIME type - let mime_type = if content_type.is_empty() { - from_path(&abs_path) - .first_or_octet_stream() - .to_string() - } else { - content_type - }; - - // Update the ID mapping for this path - self.id_mapping_service.update_path(&id, &file_storage_path).await - .map_err(|e| { - // Domain errors should be mapped to appropriate FileRepositoryError - if e.kind == crate::common::errors::ErrorKind::NotFound { - // If no previous mapping exists, treat this as a new mapping - tracing::info!("No existing ID mapping found for {}, creating new mapping", id); - FileRepositoryError::Other("ID not found in mapping, but continuing with new mapping".to_string()) - } else { - FileRepositoryError::from(e) - } - })?; - - // Keep a string representation of the path for logging - let path_string = file_storage_path.to_string(); - - // Create the file entity with the provided ID - let file = self.create_file_entity( - id.clone(), - name, - file_storage_path, - size, - mime_type, - folder_id, - Some(created_at), - Some(modified_at), - ).await?; - - // Save changes to mapping service - self.id_mapping_service.save_changes().await?; - - tracing::info!("Saved file with specific ID: {} at path: {}", id, path_string); - Ok(file) - } - - async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult { - // Find path by ID using the mapping service - let storage_path = self.id_mapping_service.get_path_by_id(id).await - .map_err(FileRepositoryError::from)?; - - // Check if file exists physically - let abs_path = self.resolve_storage_path(&storage_path); - if !abs_path.exists() || !abs_path.is_file() { - tracing::error!("File not found at path: {}", abs_path.display()); - return Err(FileRepositoryError::NotFound(format!("File {} not found at {}", id, storage_path.to_string()))); - } - - // Get file metadata - let (size, created_at, modified_at) = self.get_file_metadata(&abs_path).await?; - - // Get file name from the storage path - let name = match storage_path.file_name() { - Some(name) => name, - None => { - tracing::error!("Invalid file path: {}", storage_path.to_string()); - return Err(FileRepositoryError::InvalidPath(storage_path.to_string())); - } - }; - - // Determine parent folder ID - we need to handle this based on storage path - // This is a simplification - in a real system we might need to look up the folder ID - let parent = storage_path.parent(); - let folder_id: Option = if parent.is_none() || parent.as_ref().unwrap().is_empty() { - None // Root folder - } else { - // For simplicity, we'll leave this as None for now - // In a real implementation, you would look up the parent folder ID - None - }; - - // Determine MIME type - let mime_type = from_path(&abs_path) - .first_or_octet_stream() - .to_string(); - - // Create file entity - let file = self.create_file_entity( - id.to_string(), - name, - storage_path, - size, - mime_type, - folder_id, - Some(created_at), - Some(modified_at), - ).await?; - - Ok(file) - } - - async fn list_files(&self, folder_id: Option<&str>) -> FileRepositoryResult> { - tracing::info!("Listing files in folder_id: {:?}", folder_id); - - // Si estamos en modo desarrollo, listamos todos los archivos del directorio raíz - // para facilitar el testing - let base_storage_path = self.root_path.clone(); - let is_dev_mode = true; // Hard-code development mode para debugging - - if is_dev_mode && folder_id.is_none() { - tracing::info!("Modo desarrollo activado: listando todos los archivos en el directorio raíz"); - - let mut files_result = Vec::new(); - - // Listar archivos en el directorio raíz - match fs::read_dir(&base_storage_path).await { - Ok(mut entries) => { - while let Some(entry) = entries.next_entry().await.unwrap_or(None) { - let path = entry.path(); - - // Skip if not a file or if it's a hidden/special file - if !path.is_file() { - continue; - } - - let file_name = entry.file_name().to_string_lossy().to_string(); - if file_name.starts_with('.') || file_name == "folder_ids.json" || file_name == "file_ids.json" { - continue; - } - - // Get file metadata - let metadata = match fs::metadata(&path).await { - Ok(m) => m, - Err(e) => { - tracing::error!("Error getting metadata for {:?}: {}", path, e); - continue; - } - }; - - // Generate consistent ID for the file based on name - let storage_path = StoragePath::from_string(&file_name); - let id = Uuid::new_v4().to_string(); - - // Extract file properties - let size = metadata.len(); - let created_at = metadata.created() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or(0); - let modified_at = metadata.modified() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or(0); - - // Determine MIME type - let mime_type = from_path(&path) - .first_or_octet_stream() - .to_string(); - - // Create file entity - let file = File::with_timestamps( - id, - file_name, - storage_path, - size, - mime_type, - None, // No folder ID - created_at, - modified_at, - ).unwrap(); - - files_result.push(file); - } - }, - Err(e) => { - tracing::error!("Error reading directory {:?}: {}", base_storage_path, e); - } - } - - tracing::info!("Modo desarrollo: se encontraron {} archivos en el directorio raíz", files_result.len()); - return Ok(files_result); - } - - // Si no estamos en modo desarrollo o se especificó un folder_id, seguimos la lógica normal - // Get the folder storage path - let folder_storage_path = match folder_id { - Some(id) => { - match self.storage_mediator.get_folder_path(id).await { - Ok(path) => { - tracing::info!("Found folder with path: {:?}", path); - // Convert to StoragePath - use just the folder name to avoid path duplication - // Get just the folder name to avoid path duplication - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or_else(|| &lossy); - tracing::info!("Using folder name: {} for StoragePath", folder_name); - StoragePath::from_string(folder_name) - }, - Err(e) => { - tracing::error!("Error getting folder by ID: {}: {}", id, e); - return Ok(Vec::new()); - }, - } - }, - None => StoragePath::root(), - }; - - // Get the absolute folder path without duplicate ./storage prefix - let abs_folder_path = self.path_service.resolve_path(&folder_storage_path); - tracing::info!("Absolute folder path: {:?}", abs_folder_path); - - // Check if the directory exists - if !abs_folder_path.exists() || !abs_folder_path.is_dir() { - tracing::error!("Directory does not exist or is not a directory: {:?}", abs_folder_path); - return Ok(Vec::new()); - } - - // Read directory entries - let mut files_result = Vec::new(); - - // Read the directory entries - match fs::read_dir(&abs_folder_path).await { - Ok(mut entries) => { - while let Some(entry) = entries.next_entry().await.unwrap_or(None) { - let path = entry.path(); - - // Skip if not a file - if !path.is_file() { - continue; - } - - // Skip special files - let file_name_lossy = entry.file_name().to_string_lossy().to_string(); - if file_name_lossy.starts_with('.') || file_name_lossy == "folder_ids.json" || file_name_lossy == "file_ids.json" { - continue; - } - - // Get file metadata - let metadata = match fs::metadata(&path).await { - Ok(m) => m, - Err(e) => { - tracing::error!("Error getting metadata for {:?}: {}", path, e); - continue; - } - }; - - let file_name = file_name_lossy; - let file_storage_path = folder_storage_path.join(&file_name); - - // Get or create an ID for this file - let id = match self.id_mapping_service.get_or_create_id(&file_storage_path).await { - Ok(id) => id, - Err(e) => { - tracing::error!("Error getting ID for file: {}", e); - continue; - } - }; - - // Extract metadata - let size = metadata.len(); - - // Get creation timestamp - let created_at = metadata.created() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or_else(|_| 0); - - // Get modification timestamp - let modified_at = metadata.modified() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or_else(|_| 0); - - // Determine MIME type - let mime_type = from_path(&path) - .first_or_octet_stream() - .to_string(); - - // Create file entity - match File::with_timestamps( - id, - file_name.clone(), - file_storage_path, - size, - mime_type, - folder_id.map(String::from), - created_at, - modified_at, - ) { - Ok(file) => { - tracing::info!("Added file to result list: {}", file.name()); - files_result.push(file); - }, - Err(e) => { - tracing::error!("Error creating file entity for {}: {}", file_name, e); - continue; - } - } - } - }, - Err(e) => { - tracing::error!("Error reading directory {:?}: {}", abs_folder_path, e); - return Err(FileRepositoryError::IoError(e)); - } - } - - // Persist any new ID mappings that were created - if !files_result.is_empty() { - if let Err(e) = self.id_mapping_service.save_changes().await { - tracing::error!("Error saving ID mappings: {}", e); - } - } - - tracing::info!("Found {} files in folder {:?}", files_result.len(), folder_id); - Ok(files_result) - } - - async fn delete_file(&self, id: &str) -> FileRepositoryResult<()> { - // Get the file first to check if it exists - let file = self.get_file_by_id(id).await?; - - // Delete the physical file with non-blocking approach - let abs_path = self.resolve_storage_path(file.storage_path()); - tracing::info!("Deleting physical file: {}", abs_path.display()); - - // Invalidate metadata cache for this file - self.metadata_cache.invalidate(&abs_path).await; - - // Also invalidate any parent directory caches - if let Some(parent_dir) = abs_path.parent() { - self.metadata_cache.invalidate_directory(parent_dir).await; - } - - self.delete_file_non_blocking(abs_path).await?; - - tracing::info!("Physical file deleted successfully: {}", file.storage_path().to_string()); - Ok(()) - } - - async fn delete_file_entry(&self, id: &str) -> FileRepositoryResult<()> { - // Get the file to make sure it exists - let file = self.get_file_by_id(id).await?; - - // Delete the physical file - let abs_path = self.resolve_storage_path(file.storage_path()); - tracing::info!("Deleting physical file and entry for ID: {}", id); - - // Try to delete the file with non-blocking approach, but continue even if it fails - let delete_result = self.delete_file_non_blocking(abs_path).await; - match &delete_result { - Ok(_) => tracing::info!("Physical file deleted successfully: {}", file.storage_path().to_string()), - Err(e) => tracing::warn!("Failed to delete physical file: {} - {}", file.storage_path().to_string(), e), - }; - - // Remove the ID mapping - self.id_mapping_service.remove_id(id).await - .map_err(FileRepositoryError::from)?; - - // Save the updated mappings - self.id_mapping_service.save_changes().await?; - - // Return success even if file deletion failed - we've removed the mapping - Ok(()) - } - - async fn get_file_content(&self, id: &str) -> FileRepositoryResult> { - // Get the file first to check if it exists and get the path - let file = self.get_file_by_id(id).await?; - - // Read the file content with timeout - let abs_path = self.resolve_storage_path(file.storage_path()); - - // Obtener el tamaño del archivo antes de leerlo - let metadata = time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout getting metadata: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - let file_size = metadata.len(); - - // Check if this can be loaded in memory - let can_load_in_memory = self.config.resources.can_load_in_memory(file_size); - - tracing::info!("File size: {} bytes, can load in memory: {}", file_size, can_load_in_memory); - - if !can_load_in_memory { - return Err(FileRepositoryError::Other( - format!("File too large to load in memory: {} MB (max: {} MB)", - file_size / (1024 * 1024), - self.config.resources.max_in_memory_file_size_mb) - )); - } - - // Verificar si el archivo necesita procesamiento paralelo - if self.config.resources.needs_parallel_processing(file_size, &self.config.concurrency) { - // Para archivos muy grandes, usar el procesador paralelo - tracing::info!("Using parallel file processor for large file: {}", abs_path.display()); - - // Usar el procesador pre-configurado si está disponible o crear uno nuevo - let content = if let Some(processor) = &self.parallel_processor { - tracing::debug!("Using pre-configured parallel processor with buffer pool for reading"); - processor.read_file_parallel(&abs_path).await? - } else { - tracing::debug!("Creating on-demand parallel processor for reading"); - // Importar el procesador paralelo - use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; - - // Crear procesador con la configuración actual - let processor = ParallelFileProcessor::new(self.config.clone()); - - // Realizar lectura en paralelo - processor.read_file_parallel(&abs_path).await? - }; - - tracing::info!("Successfully read {}MB file in parallel chunks", file_size / (1024 * 1024)); - return Ok(content); - } else if self.config.resources.is_large_file(file_size) { - // Para archivos grandes (pero no tanto como para paralelizar), usar spawn_blocking - tracing::info!("Using spawn_blocking for large file: {}", abs_path.display()); - - // Use spawn_blocking to prevent blocking the runtime - let abs_path_clone = abs_path.clone(); - let chunk_size = self.config.resources.chunk_size_bytes; - - // Implementación para leer archivos grandes de forma optimizada: - // 1. Creamos un buffer del tamaño exacto del archivo para evitar realocaciones - // 2. Leemos el archivo en chunks dentro del spawn_blocking - let content = task::spawn_blocking(move || -> std::io::Result> { - use std::io::{Read, BufReader}; - use std::fs::File; - - // Abre el archivo de forma bloqueante - let file = File::open(&abs_path_clone)?; - let mut reader = BufReader::with_capacity(chunk_size, file); - - // Crea un buffer del tamaño exacto del archivo - let mut buffer = Vec::with_capacity(file_size as usize); - - // Lee todo el contenido y devuelve el buffer - reader.read_to_end(&mut buffer)?; - Ok(buffer) - }).await - .map_err(|e| FileRepositoryError::Other(format!("Join error in spawn_blocking: {}", e)))? - .map_err(FileRepositoryError::IoError)?; - - return Ok(content); - } else { - // Para archivos pequeños, usar tokio's async version con timeout - let content = time::timeout( - self.config.timeouts.file_timeout(), - fs::read(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout reading file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - return Ok(content); - } - } - - async fn get_file_stream(&self, id: &str) -> FileRepositoryResult> + Send>> { - // Get the file first to check if it exists and get the path - let file = self.get_file_by_id(id).await?; - - // Open the file for reading with timeout - let abs_path = self.resolve_storage_path(file.storage_path()); - - // Obtenemos el tamaño del archivo para definir el tamaño óptimo de los chunks - let metadata = time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout getting metadata for stream: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - let file_size = metadata.len(); - let is_large = self.config.resources.is_large_file(file_size); - - // Abrimos el archivo con timeout - let file = time::timeout( - self.config.timeouts.file_timeout(), - TokioFile::open(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout opening file stream for: {}", file.storage_path().to_string())))? - .map_err(FileRepositoryError::IoError)?; - - // Definir tamaño de chunk óptimo según el tamaño del archivo - let chunk_size = if is_large { - // Para archivos grandes usamos el tamaño de chunk configurado - self.config.resources.chunk_size_bytes - } else { - // Para archivos pequeños usamos un tamaño menor para maximizar eficiencia - 4096 // 4KB standard para archivos pequeños - }; - - tracing::info!("Streaming file {} (size: {} bytes) with chunk size: {}", - abs_path.display(), file_size, chunk_size); - - // Creamos un codec con el tamaño de chunk optimizado - let codec = BytesCodec::new(); - - // Create a stream from the file, map BytesMut to Bytes, and box it - let stream = FramedRead::with_capacity(file, codec, chunk_size) - .map(|result| { - result.map(|bytes_mut| { - // Convert BytesMut to Bytes (freeze) - bytes_mut.freeze() - }) - }); - - Ok(Box::new(stream)) - } - - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option - ) -> FileRepositoryResult> + Send>> { - use tokio::io::{AsyncReadExt, AsyncSeekExt}; - - // Get the file first to check if it exists and get the path - let file = self.get_file_by_id(id).await?; - let abs_path = self.resolve_storage_path(file.storage_path()); - - // Get file metadata for size - let metadata = time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout getting metadata: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - let file_size = metadata.len(); - - // Validate range - if start >= file_size { - return Err(FileRepositoryError::Other( - format!("Range start {} is beyond file size {}", start, file_size) - )); - } - - // Calculate actual end position - let actual_end = end.map(|e| e.min(file_size - 1)).unwrap_or(file_size - 1); - let range_length = actual_end - start + 1; - - // Open file and seek to start position - let mut file_handle = time::timeout( - self.config.timeouts.file_timeout(), - TokioFile::open(&abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout opening file: {}", abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - // Seek to start position - file_handle.seek(std::io::SeekFrom::Start(start)).await - .map_err(FileRepositoryError::IoError)?; - - // Calculate optimal chunk size - let chunk_size = if range_length > 1024 * 1024 { - self.config.resources.chunk_size_bytes - } else { - 8192 // 8KB for smaller ranges - }; - - tracing::info!( - "Range streaming {} bytes={}-{} (chunk_size={})", - abs_path.display(), start, actual_end, chunk_size - ); - - // Create a limited reader that only reads up to range_length bytes - let limited_reader = file_handle.take(range_length); - - // Create stream with FramedRead - let codec = BytesCodec::new(); - let stream = FramedRead::with_capacity(limited_reader, codec, chunk_size) - .map(|result| { - result.map(|bytes_mut| bytes_mut.freeze()) - }); - - Ok(Box::new(stream)) - } - - /// Memory-maps a file for zero-copy kernel access. - /// - /// Uses mmap for files in the 10-100MB range where: - /// - Full cache (RAM) would be wasteful - /// - Streaming adds unnecessary overhead - /// - The kernel's page cache provides optimal performance - /// - /// This runs in spawn_blocking since mmap is synchronous. - async fn get_file_mmap(&self, id: &str) -> FileRepositoryResult { - use memmap2::Mmap; - - // Get file info and path - let file = self.get_file_by_id(id).await?; - let abs_path = self.resolve_storage_path(file.storage_path()); - - tracing::info!( - "🗺️ MMAP: Memory-mapping file {} ({} bytes)", - file.name(), file.size() - ); - - // Clone path for the blocking task - let path_clone = abs_path.clone(); - - // mmap is synchronous, so we use spawn_blocking - let result = task::spawn_blocking(move || -> Result { - // Open file with std::fs (blocking) - let file_handle = std::fs::File::open(&path_clone) - .map_err(FileRepositoryError::IoError)?; - - // Create memory map (unsafe but well-tested) - // SAFETY: The file is opened read-only and we don't modify it - let mmap = unsafe { Mmap::map(&file_handle) } - .map_err(FileRepositoryError::IoError)?; - - // Convert to Bytes - this creates a reference to the mapped memory - // The Bytes will keep the mmap alive until dropped - Ok(Bytes::copy_from_slice(&mmap[..])) - }).await - .map_err(|e| FileRepositoryError::Other(format!("mmap task panicked: {}", e)))?; - - result - } - - async fn move_file(&self, id: &str, target_folder_id: Option) -> FileRepositoryResult { - // Get the original file - let original_file = self.get_file_by_id(id).await?; - - // If the target folder is the same as the current one, no need to move - if original_file.folder_id() == target_folder_id.as_deref() { - tracing::info!("File is already in the target folder, no need to move"); - return Ok(original_file); - } - - // Get the target folder path - let target_folder_path = match &target_folder_id { - Some(folder_id) => { - match self.storage_mediator.get_folder_path(folder_id).await { - Ok(path) => { - // Convert to StoragePath - use just the folder name to avoid path duplication - // Get just the folder name to avoid path duplication - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or_else(|| &lossy); - tracing::info!("Target folder name: {} for StoragePath", folder_name); - StoragePath::from_string(folder_name) - }, - Err(e) => { - return Err(FileRepositoryError::Other( - format!("Could not get target folder: {}", e) - )); - } - } - }, - None => StoragePath::root() - }; - - // Create the new file path - let new_storage_path = target_folder_path.join(original_file.name()); - - // Check if a file already exists at the destination - if self.file_exists_at_storage_path(&new_storage_path).await? { - return Err(FileRepositoryError::AlreadyExists( - format!("File already exists at destination: {}", new_storage_path.to_string()) - )); - } - - // Get absolute paths - let old_abs_path = self.resolve_storage_path(original_file.storage_path()); - let new_abs_path = self.resolve_storage_path(&new_storage_path); - - // Ensure the target directory exists - self.ensure_parent_directory(&new_abs_path).await?; - - // Move the file physically with fsync (efficient rename operation) with timeout - time::timeout( - self.config.timeouts.file_timeout(), - FileSystemUtils::rename_with_sync(&old_abs_path, &new_abs_path) - ).await - .map_err(|_| FileRepositoryError::Timeout(format!("Timeout moving file from {} to {}", - old_abs_path.display(), new_abs_path.display())))? - .map_err(FileRepositoryError::IoError)?; - - tracing::info!("File moved successfully from {:?} to {:?}", old_abs_path, new_abs_path); - - // Update the ID mapping - self.id_mapping_service.update_path(id, &new_storage_path).await - .map_err(FileRepositoryError::from)?; - - // Save the updated mappings - self.id_mapping_service.save_changes().await?; - - // Create and return the updated file entity - // Create an immutable new version of the file with the updated folder - let moved_file = original_file.with_folder(target_folder_id, Some(target_folder_path)) - .map_err(|e| FileRepositoryError::Other(e.to_string()))?; - - Ok(moved_file) - } - - async fn get_file_path(&self, id: &str) -> FileRepositoryResult { - // Use the ID mapping service to get the storage path - let storage_path = self.id_mapping_service.get_path_by_id(id).await - .map_err(FileRepositoryError::from)?; - - Ok(storage_path) - } -} \ No newline at end of file diff --git a/src/infrastructure/repositories/file_fs_repository_trash.rs b/src/infrastructure/repositories/file_fs_repository_trash.rs deleted file mode 100644 index 6eb5b903..00000000 --- a/src/infrastructure/repositories/file_fs_repository_trash.rs +++ /dev/null @@ -1,368 +0,0 @@ -use std::path::PathBuf; -use tokio::fs; -use tracing::{debug, error, instrument}; - -use crate::domain::repositories::file_repository::FileRepositoryResult; -use crate::infrastructure::repositories::file_fs_repository::FileFsRepository; - -// This file contains the implementation of trash-related methods -// for the FileFsRepository file repository - -// Implementation of trash methods for the file repository -impl FileFsRepository { - // Gets the complete path to the trash directory - fn get_trash_dir(&self) -> PathBuf { - let trash_dir = self.get_root_path().join(".trash").join("files"); - debug!("Base trash directory: {}", trash_dir.display()); - trash_dir - } - - // Gets the trash directory path for a specific user (if provided) - fn get_user_trash_dir(&self, user_id: Option<&str>) -> PathBuf { - let base_trash_dir = self.get_trash_dir(); - - if let Some(uid) = user_id { - let user_trash_dir = base_trash_dir.join(uid); - debug!("User-specific trash directory: {}", user_trash_dir.display()); - user_trash_dir - } else { - // No user ID provided - this should not happen in production - tracing::warn!("No user_id provided for trash directory, this indicates a bug"); - let default_dir = base_trash_dir.join("unknown-user"); - debug!("Fallback user trash directory: {}", default_dir.display()); - default_dir - } - } - - // Creates a unique path in the trash for the file - async fn create_trash_file_path(&self, file_id: &str) -> FileRepositoryResult { - debug!("Creating trash file path for file ID: {}", file_id); - - // Get the trash directory for the default user - let user_trash_dir = self.get_user_trash_dir(None); - - // Ensure the user's trash directory exists - debug!("Ensuring user trash directory exists: {}", user_trash_dir.display()); - if !user_trash_dir.exists() { - debug!("Creating user trash directory: {}", user_trash_dir.display()); - fs::create_dir_all(&user_trash_dir).await - .map_err(|e| { - error!("Failed to create user trash directory: {}", e); - FileRepositoryError::IoError(e) - })?; - debug!("User trash directory created successfully"); - } else { - debug!("User trash directory already exists"); - } - - // Create a unique path for the file in the trash - let trash_file_path = user_trash_dir.join(file_id); - debug!("Trash file path: {}", trash_file_path.display()); - - Ok(trash_file_path) - } -} - -// Implementation of the public methods of the FileRepository trait related to trash -// Note: The FileRepository trait implementation has been moved to file_fs_repository.rs -// to avoid duplicate implementations - -// Implementation of internal methods for trash functionality -impl FileFsRepository { - /// Helper method that will be used for trash functionality - pub(crate) async fn _trash_move_to_trash(&self, file_id: &str) -> FileRepositoryResult<()> { - debug!("Moving file to trash: {}", file_id); - - // Get the physical path of the file - // We create an independent method to access the ID mapping service - debug!("Getting file path with ID: {}", file_id); - let file_path = match self.id_mapping_service().get_file_path(file_id).await { - Ok(path) => { - debug!("File path obtained: {}", path.display()); - path - }, - Err(e) => { - error!("Error getting file path {}: {:?}", file_id, e); - return Err(FileRepositoryError::IdMappingError(format!("Failed to get file path: {}", e))); - } - }; - - // Verify that the file exists - debug!("Verifying that the file exists: {}", file_path.display()); - if !self.file_exists(&file_path).await? { - error!("File not found at the specified path: {}", file_path.display()); - return Err(FileRepositoryError::NotFound(format!("File not found: {}", file_id))); - } - debug!("File found, continuing with the operation"); - - // Create directory in trash if it doesn't exist - debug!("Creating path for file in trash"); - let trash_file_path = self.create_trash_file_path(file_id).await?; - debug!("Path in trash: {}", trash_file_path.display()); - - // Physically move the file to trash (doesn't update mappings) - debug!("Physically moving file to trash: {} -> {}", file_path.display(), trash_file_path.display()); - match fs::rename(&file_path, &trash_file_path).await { - Ok(_) => { - debug!("File successfully moved to trash: {} -> {}", file_path.display(), trash_file_path.display()); - - // Invalidate the cache for the original file - debug!("Invalidating cache for: {}", file_path.display()); - self.metadata_cache().invalidate(&file_path).await; - - // Update the mapping to the new path in trash - debug!("Updating ID mapping to new path in trash"); - if let Err(e) = self.id_mapping_service().update_file_path(file_id, &trash_file_path).await { - error!("Error updating file mapping in trash: {}", e); - return Err(FileRepositoryError::MappingError(format!("Failed to update mapping: {}", e))); - } - debug!("Mapping successfully updated"); - - debug!("Move to trash operation completed successfully for file: {}", file_id); - Ok(()) - }, - Err(e) => { - error!("Error moving file to trash: {} -> {}: {}", - file_path.display(), trash_file_path.display(), e); - Err(FileRepositoryError::IoError(e)) - } - } - } - - /// Restores a file from trash to its original location - #[instrument(skip(self))] - pub(crate) async fn _trash_restore_from_trash(&self, file_id: &str, original_path: &str) -> FileRepositoryResult<()> { - debug!("Restoring file {} to {}", file_id, original_path); - - // Try to get the current path from the ID mapping service - let current_path_result = self.id_mapping_service().get_file_path(file_id).await; - - match current_path_result { - Ok(current_path) => { - debug!("Current path in trash: {}", current_path.display()); - - // Check if the file exists in the trash - let file_exists = match fs::metadata(¤t_path).await { - Ok(_) => { - debug!("File exists in trash"); - true - }, - Err(e) => { - debug!("File does not exist in trash: {} - {}", current_path.display(), e); - false - } - }; - - if !file_exists { - error!("The file does not physically exist in the trash: {}", current_path.display()); - return Err(FileRepositoryError::NotFound(format!("File not found in trash: {}", file_id))); - } - - // Parse the original path to a PathBuf - let original_path_buf = PathBuf::from(original_path); - debug!("Original path for restoration: {}", original_path_buf.display()); - - // Check if a file already exists at the destination - let target_exists = fs::metadata(&original_path_buf).await.is_ok(); - if target_exists { - debug!("A file already exists at the destination path, generating alternative path"); - - // Generate a unique path by adding a suffix - // Extract filename and extension - let file_name = original_path_buf.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| "restored_file".to_string()); - - let parent_dir = original_path_buf.parent() - .unwrap_or_else(|| std::path::Path::new("")); - - let (stem, ext) = if let Some(dot_pos) = file_name.rfind('.') { - (file_name[..dot_pos].to_string(), file_name[dot_pos..].to_string()) - } else { - (file_name, "".to_string()) - }; - - // Create a new name with a timestamp - let timestamp = chrono::Utc::now().timestamp(); - let new_name = format!("{}_{}{}", stem, timestamp, ext); - - // Create the alternative path - let alternative_path = parent_dir.join(new_name); - debug!("Alternative path for restoration: {}", alternative_path.display()); - - // Ensure the parent directory exists - if let Some(parent) = alternative_path.parent() { - if !parent.exists() { - debug!("Creating parent directory for restoration: {}", parent.display()); - match fs::create_dir_all(parent).await { - Ok(_) => debug!("Parent directory created successfully"), - Err(e) => { - error!("Error creating parent directory: {} - {}", parent.display(), e); - return Err(FileRepositoryError::IoError(e)); - } - } - } - } - - // Move the file from trash to the alternative location - debug!("Moving file from trash to alternative location: {} -> {}", - current_path.display(), alternative_path.display()); - match fs::rename(¤t_path, &alternative_path).await { - Ok(_) => { - debug!("File successfully restored to alternative location"); - - // Invalidate cache entries - debug!("Invalidating cache for file in trash"); - self.metadata_cache().invalidate(¤t_path).await; - - // Update the ID mapping - debug!("Updating ID mapping to new location"); - if let Err(e) = self.id_mapping_service().update_file_path(file_id, &alternative_path).await { - error!("Error updating mapping of restored file: {}", e); - return Err(FileRepositoryError::MappingError( - format!("Failed to update mapping: {}", e) - )); - } - - debug!("Restoration to alternative location completed successfully"); - Ok(()) - }, - Err(e) => { - error!("Error restoring file to alternative location: {}", e); - Err(FileRepositoryError::IoError(e)) - } - } - } else { - // Ensure the parent directory exists - if let Some(parent) = original_path_buf.parent() { - if !parent.exists() { - debug!("Creating parent directory for restoration: {}", parent.display()); - match fs::create_dir_all(parent).await { - Ok(_) => debug!("Parent directory created successfully"), - Err(e) => { - error!("Error creating parent directory: {} - {}", parent.display(), e); - return Err(FileRepositoryError::IoError(e)); - } - } - } - } - - // Move the file from trash to its original location - debug!("Moving file from trash to original location: {} -> {}", - current_path.display(), original_path_buf.display()); - match fs::rename(¤t_path, &original_path_buf).await { - Ok(_) => { - debug!("File successfully restored to original location"); - - // Invalidate cache entries - debug!("Invalidating cache for file in trash"); - self.metadata_cache().invalidate(¤t_path).await; - - // Update the ID mapping - debug!("Updating ID mapping to original location"); - if let Err(e) = self.id_mapping_service().update_file_path(file_id, &original_path_buf).await { - error!("Error updating mapping of restored file: {}", e); - return Err(FileRepositoryError::MappingError( - format!("Failed to update mapping: {}", e) - )); - } - - debug!("Restoration to original location completed successfully"); - Ok(()) - }, - Err(e) => { - error!("Error restoring file to original location: {}", e); - Err(FileRepositoryError::IoError(e)) - } - } - } - }, - Err(e) => { - error!("Error getting current path of file {}: {:?}", file_id, e); - - // Check if the error is because the ID was not found - if format!("{}", e).contains("not found") { - debug!("ID not found in mapping, file no longer exists in trash"); - return Err(FileRepositoryError::NotFound(format!("File not found in trash: {}", file_id))); - } - - return Err(FileRepositoryError::IdMappingError( - format!("Failed to get file path: {}", e) - )); - } - } - } - - /// Permanently deletes a file (used by trash) - #[instrument(skip(self))] - pub(crate) async fn _trash_delete_file_permanently(&self, file_id: &str) -> FileRepositoryResult<()> { - debug!("Permanently deleting file: {}", file_id); - - // Get the file path using the ID mapping service - let file_path_result = self.id_mapping_service().get_file_path(file_id).await; - - match file_path_result { - Ok(file_path) => { - debug!("Found path for file: {} -> {}", file_id, file_path.display()); - - // Check if the file physically exists before attempting to delete - let file_exists = fs::metadata(&file_path).await.is_ok(); - - if file_exists { - debug!("File exists physically, deleting: {}", file_path.display()); - - // Delete the file physically - if let Err(e) = fs::remove_file(&file_path).await { - error!("Error permanently deleting file: {} - {}", file_path.display(), e); - // Don't report error if the file already doesn't exist - if e.kind() != std::io::ErrorKind::NotFound { - return Err(FileRepositoryError::IoError(e)); - } - } else { - debug!("File physically deleted successfully"); - } - - // Invalidate cache for this file - debug!("Invalidating cache for file: {}", file_path.display()); - self.metadata_cache().invalidate(&file_path).await; - } else { - debug!("File does not exist physically, only cleaning mappings: {}", file_path.display()); - } - - // Always remove the ID mapping regardless of whether the file exists - debug!("Removing ID mapping: {}", file_id); - match self.id_mapping_service().remove_id(file_id).await { - Ok(_) => debug!("ID mapping successfully removed"), - Err(e) => { - error!("Error removing file mapping: {}", e); - // Only return error for critical mapping errors, otherwise continue - if format!("{}", e).contains("not found") { - debug!("ID mapping not found, ignoring this error for deletion"); - } else { - return Err(FileRepositoryError::MappingError(format!("Failed to remove mapping: {}", e))); - } - } - }; - - debug!("File permanently deleted successfully: {}", file_id); - Ok(()) - }, - Err(e) => { - // This could happen if the file is already deleted or wasn't properly indexed - error!("Error getting file path {}: {:?}", file_id, e); - - // Check if the error is because the ID was not found - if format!("{}", e).contains("not found") { - debug!("ID not found in mapping, considering deletion successful: {}", file_id); - // In this case, we consider the file already deleted - return Ok(()); - } - - return Err(FileRepositoryError::IdMappingError(format!("Failed to get file path: {}", e))); - } - } - } -} - -// Re-exports needed for the compiler -use crate::domain::repositories::file_repository::FileRepositoryError; \ No newline at end of file diff --git a/src/infrastructure/repositories/file_fs_write_repository.rs b/src/infrastructure/repositories/file_fs_write_repository.rs index 413c54a3..72b44bed 100644 --- a/src/infrastructure/repositories/file_fs_write_repository.rs +++ b/src/infrastructure/repositories/file_fs_write_repository.rs @@ -1,103 +1,223 @@ use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; +use tokio::{fs, time}; +use tokio::fs::File as TokioFile; +use tokio::io::AsyncWriteExt; +use futures::{Stream, StreamExt}; +use bytes::Bytes; +use mime_guess::from_path; +use tokio::task; use crate::domain::entities::file::File; use crate::application::ports::storage_ports::FileWritePort; use crate::common::errors::DomainError; -use crate::domain::repositories::file_repository::FileRepositoryResult; -use crate::infrastructure::repositories::file_metadata_manager::{FileMetadataManager, MetadataError}; -use crate::infrastructure::repositories::file_path_resolver::FilePathResolver; -use crate::domain::services::path_service::StoragePath; -use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; -use crate::common::config::AppConfig; -use crate::application::services::storage_mediator::StorageMediator; +use crate::infrastructure::repositories::repository_errors::{FileRepositoryResult, FileRepositoryError}; use crate::infrastructure::services::file_system_utils::FileSystemUtils; +use crate::application::ports::cache_ports::MetadataCachePort; +use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; +use crate::application::services::storage_mediator::StorageMediator; +use crate::infrastructure::services::path_service::PathService; +use crate::domain::services::path_service::StoragePath; +use crate::common::config::AppConfig; -/// Implementación de repositorio para operaciones de escritura de archivos +/// Implementación de repositorio para operaciones de **escritura** de archivos. +/// +/// Implementa `FileWritePort`: +/// save_file, save_file_from_stream, move_file, delete_file, +/// update_file_content, register_file_deferred. pub struct FileFsWriteRepository { - metadata_manager: Arc, - path_resolver: Arc, + root_path: PathBuf, + storage_mediator: Arc, + id_mapping_service: Arc, + path_service: Arc, + metadata_cache: Arc, config: AppConfig, + parallel_processor: Option>, } impl FileFsWriteRepository { - /// Crea un nuevo repositorio de escritura de archivos + /// Constructor completo con todas las dependencias. pub fn new( - _root_path: PathBuf, - metadata_manager: Arc, - path_resolver: Arc, - _storage_mediator: Arc, + root_path: PathBuf, + storage_mediator: Arc, + id_mapping_service: Arc, + path_service: Arc, + metadata_cache: Arc, config: AppConfig, - _parallel_processor: Option>, + parallel_processor: Option>, ) -> Self { - Self { - metadata_manager, - path_resolver, - config, - } + Self { root_path, storage_mediator, id_mapping_service, path_service, metadata_cache, config, parallel_processor } } - - /// Crea un stub para pruebas + + /// Stub para pruebas (no realiza I/O real). pub fn default_stub() -> Self { Self { - metadata_manager: Arc::new(FileMetadataManager::default()), - path_resolver: Arc::new(FilePathResolver::default_stub()), + root_path: PathBuf::from("./storage"), + storage_mediator: Arc::new( + crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub(), + ), + id_mapping_service: Arc::new(crate::common::stubs::StubIdMappingPort), + path_service: Arc::new(PathService::new(PathBuf::from("./storage"))), + metadata_cache: Arc::new( + crate::infrastructure::services::file_metadata_cache::FileMetadataCache::default() + ) as Arc, config: AppConfig::default(), + parallel_processor: None, } } - - /// Crea directorios padres si es necesario, con sincronización + + // ─── helpers ───────────────────────────────────────────── + + fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { + self.path_service.resolve_path(storage_path) + } + async fn ensure_parent_directory(&self, abs_path: &PathBuf) -> FileRepositoryResult<()> { if let Some(parent) = abs_path.parent() { - tokio::time::timeout( + time::timeout( self.config.timeouts.dir_timeout(), - FileSystemUtils::create_dir_with_sync(parent) + FileSystemUtils::create_dir_with_sync(parent), ).await - .map_err(|_| crate::domain::repositories::file_repository::FileRepositoryError::Timeout( - format!("Timeout creating parent directory: {}", parent.display()) - ))? - .map_err(crate::domain::repositories::file_repository::FileRepositoryError::IoError)?; + .map_err(|_| FileRepositoryError::StorageError(format!("Timeout creating dir: {}", parent.display())))? + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; } Ok(()) } - - /// Crea una entidad de archivo a partir de metadatos - async fn create_file_entity( - &self, - id: String, - name: String, - storage_path: StoragePath, - size: u64, - mime_type: String, - folder_id: Option, - created_at: Option, - modified_at: Option, - ) -> FileRepositoryResult { - // If timestamps are provided, use them; otherwise, let File::new create default timestamps - if let (Some(created), Some(modified)) = (created_at, modified_at) { - File::with_timestamps( - id, - name, - storage_path, - size, - mime_type, - folder_id, - created, - modified, - ) - .map_err(|e| crate::domain::repositories::file_repository::FileRepositoryError::Other(e.to_string())) - } else { - File::new( - id, - name, - storage_path, - size, - mime_type, - folder_id, - ) - .map_err(|e| crate::domain::repositories::file_repository::FileRepositoryError::Other(e.to_string())) + + async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> FileRepositoryResult { + let abs = self.resolve_storage_path(storage_path); + if let Some(is_file) = self.metadata_cache.is_file(&abs).await { + return Ok(is_file); } + match time::timeout(self.config.timeouts.file_timeout(), fs::metadata(&abs)).await { + Ok(Ok(m)) => { + let _ = self.metadata_cache.refresh_metadata(&abs).await; + Ok(m.is_file()) + } + Ok(Err(_)) => Ok(false), + Err(_) => Err(FileRepositoryError::StorageError(format!("Timeout: {}", abs.display()))), + } + } + + async fn get_file_metadata_raw(&self, abs_path: &PathBuf) -> FileRepositoryResult<(u64, u64, u64)> { + if let Some(cached) = self.metadata_cache.get_metadata(abs_path).await { + if let (Some(s), Some(c), Some(m)) = (cached.size, cached.created_at, cached.modified_at) { + return Ok((s, c, m)); + } + } + let meta = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(abs_path)) + .await + .map_err(|_| FileRepositoryError::StorageError(format!("Timeout: {}", abs_path.display())))? + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; + let s = meta.len(); + let c = meta.created().map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()).unwrap_or(0); + let m = meta.modified().map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()).unwrap_or(0); + let _ = self.metadata_cache.refresh_metadata(abs_path).await; + Ok((s, c, m)) + } + + /// Resolve folder to StoragePath + async fn resolve_folder_path(&self, folder_id: &Option) -> StoragePath { + match folder_id { + Some(id) => match self.storage_mediator.get_folder_path(id).await { + Ok(path) => { + let lossy = path.to_string_lossy().to_string(); + let folder_name = path.file_name().and_then(|f| f.to_str()).unwrap_or(&lossy); + StoragePath::from_string(folder_name) + } + Err(_) => StoragePath::root(), + }, + None => StoragePath::root(), + } + } + + /// Generate unique file path avoiding name collisions. + async fn unique_file_path( + &self, + folder_path: &StoragePath, + name: &str, + ) -> FileRepositoryResult<(StoragePath, String)> { + let mut file_path = folder_path.join(name); + let mut actual_name = name.to_string(); + let mut counter = 1; + while self.file_exists_at_storage_path(&file_path).await? { + let (stem, ext) = if let Some(dot) = name.rfind('.') { + (name[..dot].to_string(), name[dot..].to_string()) + } else { + (name.to_string(), String::new()) + }; + actual_name = format!("{}_{}{}", stem, counter, ext); + file_path = folder_path.join(&actual_name); + counter += 1; + } + Ok((file_path, actual_name)) + } + + async fn delete_file_non_blocking(&self, abs_path: PathBuf) -> FileRepositoryResult<()> { + let file_size = match fs::metadata(&abs_path).await { + Ok(m) => m.len(), + Err(_) => 0, + }; + if self.config.resources.is_large_file(file_size) { + task::spawn_blocking(move || { let _ = std::fs::remove_file(&abs_path); }) + .await + .map_err(|e| FileRepositoryError::Other(e.to_string()))?; + } else { + time::timeout(self.config.timeouts.file_timeout(), fs::remove_file(&abs_path)) + .await + .map_err(|_| FileRepositoryError::StorageError("Timeout deleting file".into()))? + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; + } + Ok(()) + } + + /// Persist ID mapping with retry + verification. + async fn persist_id_mapping(&self, id: &str, expected_path: &str) -> FileRepositoryResult<()> { + for attempt in 1..=3 { + match self.id_mapping_service.save_changes().await { + Ok(_) => { + if let Ok(verified) = self.id_mapping_service.get_path_by_id(id).await { + if verified.to_string() == expected_path { + return Ok(()); + } + } + if attempt == 3 { + return Err(FileRepositoryError::Other("Failed to verify ID mapping after 3 attempts".into())); + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + Err(e) if attempt < 3 => { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tracing::warn!("ID mapping save retry {}: {}", attempt, e); + } + Err(e) => return Err(FileRepositoryError::Other(format!("Save ID mapping failed: {}", e))), + } + } + Ok(()) + } +} + +impl Clone for FileFsWriteRepository { + fn clone(&self) -> Self { + Self { + root_path: self.root_path.clone(), + storage_mediator: self.storage_mediator.clone(), + id_mapping_service: self.id_mapping_service.clone(), + path_service: self.path_service.clone(), + metadata_cache: self.metadata_cache.clone(), + config: self.config.clone(), + parallel_processor: self.parallel_processor.clone(), + } + } +} + +fn map_repo_err(e: FileRepositoryError) -> DomainError { + match e { + FileRepositoryError::NotFound(m) => DomainError::not_found("File", m), + FileRepositoryError::AlreadyExists(m) => DomainError::already_exists("File", m), + FileRepositoryError::StorageError(m) => DomainError::internal_error("File", m), + other => DomainError::internal_error("File", other.to_string()), } } @@ -110,103 +230,288 @@ impl FileWritePort for FileFsWriteRepository { content_type: String, content: Vec, ) -> Result { - // Generate a unique ID for the file - let file_id = uuid::Uuid::new_v4().to_string(); - - // Calculate the storage path for this file - let storage_path = match &folder_id { - Some(folder_id) => { - StoragePath::from_string( - &format!("/{}/{}", folder_id, name) - ) - }, - None => { - StoragePath::from_string( - &format!("/{}", name) - ) + let folder_path = self.resolve_folder_path(&folder_id).await; + let (file_storage_path, actual_name) = self.unique_file_path(&folder_path, &name).await.map_err(map_repo_err)?; + let abs_path = self.resolve_storage_path(&file_storage_path); + self.ensure_parent_directory(&abs_path).await.map_err(map_repo_err)?; + + let content_size = content.len() as u64; + + // Write strategy based on file size + if self.config.resources.needs_parallel_processing(content_size, &self.config.concurrency) { + if let Some(proc) = &self.parallel_processor { + proc.write_file_parallel(&abs_path, &content).await.map_err(map_repo_err)?; + } else { + let proc = ParallelFileProcessor::new(self.config.clone()); + proc.write_file_parallel(&abs_path, &content).await.map_err(map_repo_err)?; } - }; - - // Resolve the absolute path on disk - let abs_path = self.path_resolver.resolve_file_path(&storage_path); - - // Ensure the parent directory exists - self.ensure_parent_directory(&abs_path).await - .map_err(|e| DomainError::internal_error("File system", e.to_string()))?; - - // Write the file to disk using atomic write with fsync - tokio::time::timeout( - self.config.timeouts.file_write_timeout(), - FileSystemUtils::atomic_write(&abs_path, &content) - ).await - .map_err(|_| DomainError::internal_error( - "File write", - format!("Timeout writing file: {}", abs_path.display()) - ))? - .map_err(|e| DomainError::internal_error( - "File system", - format!("Error writing file: {} - {}", abs_path.display(), e) - ))?; - - // Create and return a File entity - let size = content.len() as u64; - let file = self.create_file_entity( - file_id, - name, - storage_path, - size, - content_type, - folder_id, - None, - None, - ).await - .map_err(|e| DomainError::internal_error("File entity creation", e.to_string()))?; - - // Save metadata - self.metadata_manager.update_file_metadata(&file) - .await - .map_err(|e| match e { - MetadataError::IoError(e) => DomainError::internal_error("File metadata", e.to_string()), - MetadataError::Timeout(msg) => DomainError::internal_error("File metadata", msg), - MetadataError::Unavailable(msg) => DomainError::not_found("File metadata", msg) - })?; - - tracing::info!("File saved successfully: {} (ID: {})", file.name(), file.id()); + } else if content_size > self.config.resources.large_file_threshold_mb * 1024 * 1024 { + let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::create(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout creating file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let chunk_size = self.config.resources.chunk_size_bytes; + for chunk in content.chunks(chunk_size) { + fh.write_all(chunk).await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + } + fh.flush().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + } else { + let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::create(&abs_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout creating file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.write_all(&content).await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.flush().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + } + + let (size, created_at, modified_at) = self.get_file_metadata_raw(&abs_path).await.map_err(map_repo_err)?; + let mime = if content_type.is_empty() { from_path(&abs_path).first_or_octet_stream().to_string() } else { content_type }; + let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let path_string = file_storage_path.to_string(); + + let file = File::with_timestamps(id.clone(), actual_name, file_storage_path, size, mime, folder_id, created_at, modified_at) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + self.persist_id_mapping(&id, &path_string).await.map_err(map_repo_err)?; + if let Some(parent) = abs_path.parent() { + self.metadata_cache.invalidate_directory(parent).await; + } Ok(file) } - - async fn move_file(&self, _file_id: &str, _target_folder_id: Option) -> Result { - // Implementación real debe mover el archivo a otra carpeta - // Por ahora, devolvemos un error - Err(DomainError::internal_error("File move", "Move functionality not yet implemented")) + + async fn save_file_from_stream( + &self, + name: String, + folder_id: Option, + content_type: String, + mut stream: std::pin::Pin> + Send>>, + ) -> Result { + let folder_path = self.resolve_folder_path(&folder_id).await; + let (file_storage_path, actual_name) = self.unique_file_path(&folder_path, &name).await.map_err(map_repo_err)?; + let abs_path = self.resolve_storage_path(&file_storage_path); + self.ensure_parent_directory(&abs_path).await.map_err(map_repo_err)?; + + let temp_path = abs_path.with_extension("tmp.upload"); + let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::create(&temp_path)) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout creating temp file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + let mut total_bytes: u64 = 0; + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.write_all(&chunk).await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + total_bytes += chunk.len() as u64; + } + fh.flush().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.sync_all().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + drop(fh); + + // Atomic rename + fs::rename(&temp_path, &abs_path).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + let (size, created_at, modified_at) = self.get_file_metadata_raw(&abs_path).await.map_err(map_repo_err)?; + let mime = if content_type.is_empty() { from_path(&abs_path).first_or_octet_stream().to_string() } else { content_type }; + let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let path_string = file_storage_path.to_string(); + let log_name = actual_name.clone(); + + let file = File::with_timestamps(id.clone(), actual_name, file_storage_path, size, mime, folder_id, created_at, modified_at) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + self.persist_id_mapping(&id, &path_string).await.map_err(map_repo_err)?; + if let Some(parent) = abs_path.parent() { + self.metadata_cache.invalidate_directory(parent).await; + } + tracing::info!("✅ STREAMING UPLOAD COMPLETE: {} ({} bytes)", log_name, total_bytes); + Ok(file) } - - async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { - // Por ahora, devolvemos OK simulando éxito - // En una implementación real, buscaríamos el archivo por ID y lo eliminaríamos - tracing::info!("File deletion simulated successfully"); + + async fn move_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result { + // Get original file + let original_path = self.id_mapping_service.get_path_by_id(file_id).await?; + let old_abs = self.resolve_storage_path(&original_path); + if !old_abs.exists() || !old_abs.is_file() { + return Err(DomainError::not_found("File", file_id.to_string())); + } + let (size, created_at, modified_at) = self.get_file_metadata_raw(&old_abs).await.map_err(map_repo_err)?; + let name = original_path.file_name() + .ok_or_else(|| DomainError::internal_error("File", "Invalid path"))?; + let mime = from_path(&old_abs).first_or_octet_stream().to_string(); + + // Build target path + let target_folder_path = self.resolve_folder_path(&target_folder_id).await; + let new_storage_path = target_folder_path.join(&name); + if self.file_exists_at_storage_path(&new_storage_path).await.map_err(map_repo_err)? { + return Err(DomainError::already_exists("File", + format!("File already exists at {}", new_storage_path.to_string()))); + } + let new_abs = self.resolve_storage_path(&new_storage_path); + self.ensure_parent_directory(&new_abs).await.map_err(map_repo_err)?; + + // Rename + time::timeout( + self.config.timeouts.file_timeout(), + FileSystemUtils::rename_with_sync(&old_abs, &new_abs), + ).await + .map_err(|_| DomainError::internal_error("File", "Timeout moving file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + // Update mapping + self.id_mapping_service.update_path(file_id, &new_storage_path).await?; + let _ = self.id_mapping_service.save_changes().await; + + File::with_timestamps(file_id.to_string(), name, new_storage_path, size, mime, target_folder_id, created_at, modified_at) + .map_err(|e| DomainError::internal_error("File", e.to_string())) + } + + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { + let storage_path = self.id_mapping_service.get_path_by_id(id).await?; + let abs_path = self.resolve_storage_path(&storage_path); + + self.metadata_cache.invalidate(&abs_path).await; + if let Some(parent) = abs_path.parent() { + self.metadata_cache.invalidate_directory(parent).await; + } + + self.delete_file_non_blocking(abs_path).await.map_err(map_repo_err)?; Ok(()) } - - async fn get_folder_details(&self, folder_id: &str) -> Result { - // Fetch the folder information from the metadata manager - match self.metadata_manager.get_folder_by_id(folder_id).await { - Ok(folder) => Ok(folder), - Err(err) => { - tracing::warn!("Error getting folder details for ID {}: {}", folder_id, err); - Err(DomainError::not_found("Folder", folder_id.to_string())) - } - } + + async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError> { + let storage_path = self.id_mapping_service.get_path_by_id(file_id).await?; + let physical_path = self.resolve_storage_path(&storage_path); + + FileSystemUtils::atomic_write(&physical_path, &content) + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + // Refresh cache + let _ = self.metadata_cache.refresh_metadata(&physical_path).await; + Ok(()) } - - async fn get_folder_path_str(&self, folder_id: &str) -> Result { - // Fetch the folder information - let folder = self.get_folder_details(folder_id).await?; - - // Convert StoragePath to string - let path_str = folder.storage_path().to_string(); - - tracing::debug!("Resolved folder path for ID {}: {}", folder_id, path_str); - Ok(path_str) + + async fn register_file_deferred( + &self, + name: String, + folder_id: Option, + content_type: String, + size: u64, + ) -> Result<(File, PathBuf), DomainError> { + let folder_path = self.resolve_folder_path(&folder_id).await; + let (file_storage_path, actual_name) = self.unique_file_path(&folder_path, &name).await.map_err(map_repo_err)?; + let abs_path = self.resolve_storage_path(&file_storage_path); + self.ensure_parent_directory(&abs_path).await.map_err(map_repo_err)?; + + let mime = if content_type.is_empty() { + from_path(&abs_path).first_or_octet_stream().to_string() + } else { + content_type + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let _ = self.id_mapping_service.save_changes().await; + + let file = File::with_timestamps(id.clone(), actual_name, file_storage_path, size, mime, folder_id, now, now) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + tracing::debug!("⚡ Registered deferred file: {} -> {:?}", id, abs_path); + Ok((file, abs_path)) + } + + async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { + // Get the file's current path + let storage_path = self.id_mapping_service.get_path_by_id(file_id).await?; + let abs_path = self.resolve_storage_path(&storage_path); + + if !abs_path.exists() || !abs_path.is_file() { + return Err(DomainError::not_found("File", file_id.to_string())); + } + + // Create trash directory + let trash_dir = self.root_path.join(".trash").join("files"); + fs::create_dir_all(&trash_dir).await + .map_err(|e| DomainError::internal_error("File", format!("Failed to create trash dir: {}", e)))?; + + // Move file to trash + let trash_path = trash_dir.join(file_id); + fs::rename(&abs_path, &trash_path).await + .map_err(|e| DomainError::internal_error("File", format!("Failed to move file to trash: {}", e)))?; + + // Update mapping to trash location + let trash_storage_path = StoragePath::from_string(&format!(".trash/files/{}", file_id)); + self.id_mapping_service.update_path(file_id, &trash_storage_path).await?; + let _ = self.id_mapping_service.save_changes().await; + + // Invalidate cache + self.metadata_cache.invalidate(&abs_path).await; + if let Some(parent) = abs_path.parent() { + self.metadata_cache.invalidate_directory(parent).await; + } + + tracing::debug!("File moved to trash: {} -> {}", file_id, trash_path.display()); + Ok(()) + } + + async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError> { + // Get current path (should be in trash) + let current_storage_path = self.id_mapping_service.get_path_by_id(file_id).await?; + let current_abs_path = self.resolve_storage_path(¤t_storage_path); + + if !current_abs_path.exists() { + return Err(DomainError::not_found("File", format!("File {} not found in trash", file_id))); + } + + // Ensure parent directory exists for original location + let original_storage_path = StoragePath::from_string(original_path); + let original_abs_path = self.resolve_storage_path(&original_storage_path); + if let Some(parent) = original_abs_path.parent() { + fs::create_dir_all(parent).await + .map_err(|e| DomainError::internal_error("File", format!("Failed to create parent dir: {}", e)))?; + } + + // Move file back to original location + fs::rename(¤t_abs_path, &original_abs_path).await + .map_err(|e| DomainError::internal_error("File", format!("Failed to restore file: {}", e)))?; + + // Update mapping back to original path + self.id_mapping_service.update_path(file_id, &original_storage_path).await?; + let _ = self.id_mapping_service.save_changes().await; + + tracing::debug!("File restored from trash: {} -> {}", file_id, original_abs_path.display()); + Ok(()) + } + + async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> { + // Get current path (could be in trash or original location) + let storage_path = self.id_mapping_service.get_path_by_id(file_id).await?; + let abs_path = self.resolve_storage_path(&storage_path); + + // Delete the physical file if it exists + if abs_path.exists() { + self.delete_file_non_blocking(abs_path.clone()).await.map_err(map_repo_err)?; + } + + // Remove ID mapping + self.id_mapping_service.remove_id(file_id).await?; + let _ = self.id_mapping_service.save_changes().await; + + // Invalidate cache + self.metadata_cache.invalidate(&abs_path).await; + + tracing::debug!("File permanently deleted: {}", file_id); + Ok(()) } } \ No newline at end of file diff --git a/src/infrastructure/repositories/file_metadata_manager.rs b/src/infrastructure/repositories/file_metadata_manager.rs deleted file mode 100644 index 6967e77a..00000000 --- a/src/infrastructure/repositories/file_metadata_manager.rs +++ /dev/null @@ -1,223 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use tokio::time; -use tokio::fs; -use std::time::Duration; - -use crate::infrastructure::services::file_metadata_cache::{FileMetadataCache, CacheEntryType, FileMetadata}; -use crate::domain::entities::file::File; -use crate::domain::services::path_service::StoragePath; -use crate::common::config::AppConfig; -use crate::common::errors::DomainError; - -/// Gestor de metadatos de archivos que encapsula la lógica de caché -pub struct FileMetadataManager { - metadata_cache: Arc, - config: AppConfig, -} - -#[derive(Debug, thiserror::Error)] -pub enum MetadataError { - #[error("Error de E/S al acceder a los metadatos: {0}")] - IoError(#[from] std::io::Error), - - #[error("Timeout al acceder a los metadatos: {0}")] - Timeout(String), - - #[error("Metadatos no disponibles: {0}")] - Unavailable(String), -} - -impl From for DomainError { - fn from(err: MetadataError) -> Self { - match err { - MetadataError::IoError(e) => DomainError::internal_error("FileMetadata", e.to_string()), - MetadataError::Timeout(msg) => DomainError::internal_error("FileMetadata", msg), - MetadataError::Unavailable(msg) => DomainError::not_found("FileMetadata", msg), - } - } -} - -impl FileMetadataManager { - /// Crea un nuevo gestor de metadatos - pub fn new(metadata_cache: Arc, config: AppConfig) -> Self { - Self { - metadata_cache, - config, - } - } - - /// Crea un gestor por defecto para pruebas - pub fn default() -> Self { - Self { - metadata_cache: Arc::new(FileMetadataCache::default()), - config: AppConfig::default(), - } - } - - /// Comprueba si un archivo existe en la ruta especificada con caché - pub async fn file_exists(&self, abs_path: &PathBuf) -> Result { - // Intentar obtener del caché avanzado primero - if let Some(is_file) = self.metadata_cache.is_file(&abs_path).await { - tracing::debug!("Metadata cache hit for existence check: {} - path: {}", is_file, abs_path.display()); - return Ok(is_file); - } - - // Si no está en caché, verificar directamente y actualizar caché - tracing::debug!("Metadata cache miss for existence check: {}", abs_path.display()); - - // Utilizar timeout para evitar bloqueo - match time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await { - Ok(Ok(metadata)) => { - let is_file = metadata.is_file(); - - // Actualizar la caché con información fresca - if let Err(e) = self.metadata_cache.refresh_metadata(&abs_path).await { - tracing::warn!("Failed to update cache for {}: {}", abs_path.display(), e); - } - - if is_file { - tracing::debug!("File exists and is accessible: {}", abs_path.display()); - Ok(true) - } else { - tracing::warn!("Path exists but is not a file: {}", abs_path.display()); - Ok(false) - } - }, - Ok(Err(e)) => { - tracing::warn!("File check failed: {} - {}", abs_path.display(), e); - - // Añadir a caché como no existente - let entry_type = CacheEntryType::Unknown; - let file_metadata = FileMetadata::new( - abs_path.clone(), - false, - entry_type, - None, - None, - None, - None, - Duration::from_millis(self.config.timeouts.file_operation_ms), - ); - self.metadata_cache.update_cache(file_metadata).await; - - Ok(false) - }, - Err(_) => { - tracing::warn!("Timeout checking file metadata: {}", abs_path.display()); - Err(MetadataError::Timeout(format!("Timeout checking file: {}", abs_path.display()))) - } - } - } - - /// Obtiene metadatos de archivo (tamaño, fechas creación/modificación) con caché - pub async fn get_file_metadata(&self, abs_path: &PathBuf) -> Result<(u64, u64, u64), MetadataError> { - // Intentar obtener de caché primero - if let Some(cached_metadata) = self.metadata_cache.get_metadata(abs_path).await { - if let (Some(size), Some(created_at), Some(modified_at)) = - (cached_metadata.size, cached_metadata.created_at, cached_metadata.modified_at) { - tracing::debug!("Using cached metadata for: {}", abs_path.display()); - return Ok((size, created_at, modified_at)); - } - } - - // Si no está en caché o metadatos incompletos, cargar desde sistema de archivos - let metadata = match time::timeout( - self.config.timeouts.file_timeout(), - fs::metadata(&abs_path) - ).await { - Ok(Ok(metadata)) => metadata, - Ok(Err(e)) => return Err(MetadataError::IoError(e)), - Err(_) => return Err(MetadataError::Timeout( - format!("Timeout getting metadata for: {}", abs_path.display()) - )), - }; - - let size = metadata.len(); - - // Get creation timestamp - let created_at = metadata.created() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or_else(|_| 0); - - // Get modification timestamp - let modified_at = metadata.modified() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or_else(|_| 0); - - // Actualizar caché si es posible - if let Err(e) = self.metadata_cache.refresh_metadata(abs_path).await { - tracing::warn!("Failed to update metadata cache for {}: {}", abs_path.display(), e); - } - - Ok((size, created_at, modified_at)) - } - - /// Invalida la entrada de caché para un archivo - pub async fn invalidate(&self, abs_path: &PathBuf) { - self.metadata_cache.invalidate(abs_path).await; - } - - /// Invalida la entrada de caché para un directorio y su contenido - pub async fn invalidate_directory(&self, dir_path: &PathBuf) { - self.metadata_cache.invalidate_directory(dir_path).await; - } - - /// Actualiza los metadatos de un archivo en la caché - pub async fn update_file_metadata(&self, file: &crate::domain::entities::file::File) -> Result<(), MetadataError> { - // Crear una ruta absoluta para el archivo - let abs_path = PathBuf::from(format!("{}/{}", self.config.storage_path.display(), file.storage_path().to_string())); - - // Crear un objeto FileMetadata - let metadata = FileMetadataCache::create_metadata_from_file(file, abs_path.clone()); - - // Actualizar la caché - self.metadata_cache.update_cache(metadata).await; - - Ok(()) - } - - /// Obtiene información de una carpeta por ID - pub async fn get_folder_by_id(&self, folder_id: &str) -> Result { - // Implementación simplificada que solo busca en la caché de metadatos - // pero que devuelve una estructura mínima para el servicio de uso de almacenamiento - - // En una implementación real, se consultaría un índice persistente - // Para esta implementación básica, usaremos un método simplificado - - // Crear un objeto StoragePath mínimo - let storage_path = StoragePath::from_string(&format!("/{}", folder_id)); - - // Creamos una carpeta con información mínima - // Esta implementación es un placeholder - en una situación real - // consultaríamos el mapa folder_id -> folder_metadata en el sistema - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // Verificamos si el nombre contiene información del usuario - let folder_name = if folder_id.contains('-') { - // Asumimos un formato UUID v4, intentamos usar "Mi Carpeta - username" como nombre - format!("Mi Carpeta - usuario") - } else { - // Si no, usamos el ID como nombre - folder_id.to_string() - }; - - let folder = File::new_folder( - folder_id.to_string(), - folder_name, - storage_path, - None, // parent_id - now, // created_at - now, // updated_at - ) - .map_err(|e| MetadataError::Unavailable(format!("Error creating folder entity: {}", e)))?; - - Ok(folder) - } -} \ No newline at end of file diff --git a/src/infrastructure/repositories/file_path_resolver.rs b/src/infrastructure/repositories/file_path_resolver.rs deleted file mode 100644 index 1ab681ed..00000000 --- a/src/infrastructure/repositories/file_path_resolver.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use async_trait::async_trait; - -use crate::domain::services::path_service::StoragePath; -use crate::infrastructure::services::path_service::PathService; -use crate::application::services::storage_mediator::StorageMediator; -// use crate::application::ports::outbound::IdMappingPort; -use crate::domain::repositories::file_repository::FileRepositoryError; -use crate::common::errors::DomainError; -use crate::application::ports::storage_ports::FilePathResolutionPort; - -/// Resuelve rutas de archivos y gestiona el mapeo de IDs a rutas -pub struct FilePathResolver { - path_service: Arc, - storage_mediator: Arc, - id_mapping_service: Arc, -} - -impl FilePathResolver { - /// Crea un nuevo resolver de rutas - pub fn new( - path_service: Arc, - storage_mediator: Arc, - id_mapping_service: Arc, - ) -> Self { - Self { - path_service, - storage_mediator, - id_mapping_service, - } - } - - /// Crea un resolver de rutas de prueba - pub fn default_stub() -> Self { - let path_service = Arc::new(PathService::new(PathBuf::from("./storage"))); - - // Create dummy implementation of IdMappingPort - struct DummyIdMappingService; - #[async_trait::async_trait] - impl crate::application::ports::outbound::IdMappingPort for DummyIdMappingService { - async fn get_or_create_id(&self, _path: &StoragePath) -> Result { - Ok("dummy-id".to_string()) - } - - async fn get_path_by_id(&self, _id: &str) -> Result { - Ok(StoragePath::from_string("/")) - } - - async fn update_path(&self, _id: &str, _new_path: &StoragePath) -> Result<(), DomainError> { - Ok(()) - } - - async fn remove_id(&self, _id: &str) -> Result<(), DomainError> { - Ok(()) - } - - async fn save_changes(&self) -> Result<(), DomainError> { - Ok(()) - } - } - - Self { - path_service: path_service.clone(), - storage_mediator: Arc::new(crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub()), - id_mapping_service: Arc::new(DummyIdMappingService) as Arc, - } - } - - /// Resuelve una ruta de dominio a una ruta física absoluta - pub fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { - self.path_service.resolve_path(storage_path) - } - - /// Resuelve la ruta de un archivo (alias para resolve_storage_path) - pub fn resolve_file_path(&self, storage_path: &StoragePath) -> PathBuf { - self.resolve_storage_path(storage_path) - } - - /// Resuelve una ruta PathBuf a una ruta física absoluta (legacy) - pub fn resolve_legacy_path(&self, relative_path: &std::path::Path) -> PathBuf { - self.storage_mediator.resolve_path(relative_path) - } - - /// Obtiene la ruta de un archivo por su ID - pub async fn get_path_by_id(&self, id: &str) -> Result { - self.id_mapping_service.get_path_by_id(id).await - .map_err(|e| FileRepositoryError::IdMappingError(e.to_string())) - } - - /// Actualiza la ruta para un ID existente - pub async fn update_path(&self, id: &str, storage_path: &StoragePath) -> Result<(), FileRepositoryError> { - self.id_mapping_service.update_path(id, storage_path).await - .map_err(|e| FileRepositoryError::IdMappingError(e.to_string())) - } - - /// Obtiene o crea un ID para una ruta - pub async fn get_or_create_id(&self, storage_path: &StoragePath) -> Result { - self.id_mapping_service.get_or_create_id(storage_path).await - .map_err(|e| FileRepositoryError::IdMappingError(e.to_string())) - } - - /// Elimina un ID del mapeo - pub async fn remove_id(&self, id: &str) -> Result<(), FileRepositoryError> { - self.id_mapping_service.remove_id(id).await - .map_err(|e| FileRepositoryError::IdMappingError(e.to_string())) - } - - /// Guarda cambios pendientes - pub async fn save_changes(&self) -> Result<(), FileRepositoryError> { - self.id_mapping_service.save_changes().await - .map_err(|e| FileRepositoryError::IdMappingError(e.to_string())) - } -} - -// Implementación de FilePathResolutionPort -#[async_trait] -impl FilePathResolutionPort for FilePathResolver { - async fn get_file_path(&self, id: &str) -> Result { - self.get_path_by_id(id).await - .map_err(|e| match e { - FileRepositoryError::NotFound(id) => DomainError::not_found("File", id), - FileRepositoryError::IoError(e) => DomainError::internal_error("FilePath", e.to_string()), - FileRepositoryError::Timeout(msg) => DomainError::internal_error("FilePath", msg), - _ => DomainError::internal_error("FilePath", e.to_string()), - }) - } - - fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { - self.resolve_storage_path(storage_path) - } -} \ No newline at end of file diff --git a/src/infrastructure/repositories/folder_fs_repository.rs b/src/infrastructure/repositories/folder_fs_repository.rs index b8248f4c..52510ad4 100644 --- a/src/infrastructure/repositories/folder_fs_repository.rs +++ b/src/infrastructure/repositories/folder_fs_repository.rs @@ -4,18 +4,17 @@ use std::time::Duration; use async_trait::async_trait; use tokio::fs; use tokio::time::timeout; -use tracing::instrument; use crate::domain::entities::folder::{Folder, FolderError}; -use crate::domain::repositories::folder_repository::{ - FolderRepository, FolderRepositoryError, FolderRepositoryResult +use crate::infrastructure::repositories::repository_errors::{ + FolderRepositoryError, FolderRepositoryResult }; use crate::domain::services::path_service::StoragePath; use crate::infrastructure::services::path_service::PathService; // use crate::application::ports::outbound::IdMappingPort; use crate::infrastructure::services::id_mapping_service::{IdMappingService, IdMappingError}; use crate::application::services::storage_mediator::StorageMediator; -use crate::application::ports::outbound::FolderStoragePort; +use crate::domain::repositories::folder_repository::FolderRepository; use crate::common::errors::DomainError; // To be able to use streams in the list_folders function @@ -88,7 +87,7 @@ impl FolderFsRepository { match read_dir_result { Ok(result) => { - let mut entries = result.map_err(FolderRepositoryError::IoError)?; + let mut entries = result.map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; let mut count = 0; // Count entries manually @@ -111,20 +110,22 @@ impl FolderFsRepository { self.path_service.resolve_path(storage_path) } - /// Resolves a legacy PathBuf to an absolute filesystem path - fn resolve_legacy_path(&self, relative_path: &std::path::Path) -> PathBuf { - self.storage_mediator.resolve_path(relative_path) - } - /// Returns a reference to the ID mapping service pub fn id_mapping_service(&self) -> &Arc { &self.id_mapping_service } + + /// Gets the storage path for a folder by its ID (internal helper) + async fn _get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult { + let storage_path = self.id_mapping_service.get_path_by_id(id).await + .map_err(FolderRepositoryError::from)?; + Ok(storage_path) + } /// Gets a folder path from the ID mapping service pub async fn get_mapped_folder_path(&self, folder_id: &str) -> FolderRepositoryResult { let storage_path = self.id_mapping_service.get_path_by_id(folder_id).await - .map_err(|e| FolderRepositoryError::MappingError(format!("Failed to get folder path: {}", e)))?; + .map_err(|e| FolderRepositoryError::StorageError(format!("Failed to get folder path: {}", e)))?; Ok(storage_path.to_string()) } @@ -132,13 +133,13 @@ impl FolderFsRepository { pub async fn update_mapped_folder_path(&self, folder_id: &str, new_path: &PathBuf) -> FolderRepositoryResult<()> { let storage_path = StoragePath::from_string(&new_path.to_string_lossy().to_string()); self.id_mapping_service.update_path(folder_id, &storage_path).await - .map_err(|e| FolderRepositoryError::MappingError(format!("Failed to update folder path: {}", e))) + .map_err(|e| FolderRepositoryError::StorageError(format!("Failed to update folder path: {}", e))) } /// Removes a folder ID from the ID mapping service pub async fn remove_mapped_folder_id(&self, folder_id: &str) -> FolderRepositoryResult<()> { self.id_mapping_service.remove_id(folder_id).await - .map_err(|e| FolderRepositoryError::MappingError(format!("Failed to remove folder ID: {}", e))) + .map_err(|e| FolderRepositoryError::StorageError(format!("Failed to remove folder ID: {}", e))) } /// Checks if a folder exists at a given storage path @@ -199,7 +200,7 @@ impl FolderFsRepository { /// Extracts folder metadata from a physical path async fn get_folder_metadata(&self, abs_path: &PathBuf) -> FolderRepositoryResult<(u64, u64)> { let metadata = fs::metadata(&abs_path).await - .map_err(FolderRepositoryError::IoError)?; + .map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; // Get creation timestamp let created_at = metadata.created() @@ -220,43 +221,9 @@ impl From for FolderRepositoryError { fn from(err: IdMappingError) -> Self { match err { IdMappingError::NotFound(id) => FolderRepositoryError::NotFound(id), - IdMappingError::IoError(e) => FolderRepositoryError::IoError(e), - IdMappingError::Timeout(msg) => FolderRepositoryError::Other(format!("Timeout: {}", msg)), - _ => FolderRepositoryError::MappingError(err.to_string()), - } - } -} - -// Convert FolderRepositoryError to DomainError -impl From for DomainError { - fn from(err: FolderRepositoryError) -> Self { - match err { - FolderRepositoryError::NotFound(id) => { - DomainError::not_found("Folder", id) - }, - FolderRepositoryError::AlreadyExists(path) => { - DomainError::already_exists("Folder", path) - }, - FolderRepositoryError::InvalidPath(path) => { - DomainError::validation_error(format!("Invalid path: {}", path)) - }, - FolderRepositoryError::IoError(e) => { - DomainError::internal_error("Folder", format!("IO error: {}", e)) - .with_source(e) - }, - FolderRepositoryError::ValidationError(msg) => { - DomainError::validation_error(msg) - }, - FolderRepositoryError::MappingError(msg) => { - DomainError::internal_error("Folder", format!("Mapping error: {}", msg)) - }, - FolderRepositoryError::Other(msg) => { - DomainError::internal_error("Folder", msg) - }, - FolderRepositoryError::OperationNotSupported(msg) => { - DomainError::operation_not_supported("Folder", msg) - }, - FolderRepositoryError::DomainError(e) => e, + IdMappingError::IoError(e) => FolderRepositoryError::StorageError(e.to_string()), + IdMappingError::Timeout(msg) => FolderRepositoryError::StorageError(format!("Timeout: {}", msg)), + _ => FolderRepositoryError::StorageError(err.to_string()), } } } @@ -274,88 +241,20 @@ impl Clone for FolderFsRepository { } } -#[async_trait] -impl FolderStoragePort for FolderFsRepository { - async fn create_folder(&self, name: String, parent_id: Option) -> Result { - FolderRepository::create_folder(self, name, parent_id).await.map_err(DomainError::from) - } - - async fn get_folder(&self, id: &str) -> Result { - FolderRepository::get_folder_by_id(self, id).await.map_err(DomainError::from) - } - - async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { - FolderRepository::get_folder_by_storage_path(self, storage_path).await.map_err(DomainError::from) - } - - async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { - FolderRepository::list_folders(self, parent_id).await.map_err(DomainError::from) - } - - async fn rename_folder(&self, id: &str, new_name: String) -> Result { - FolderRepository::rename_folder(self, id, new_name).await.map_err(DomainError::from) - } - - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result { - FolderRepository::move_folder(self, id, new_parent_id).await.map_err(DomainError::from) - } - - async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { - FolderRepository::delete_folder(self, id).await.map_err(DomainError::from) - } - - async fn folder_exists(&self, storage_path: &StoragePath) -> Result { - FolderRepository::folder_exists_at_storage_path(self, storage_path).await.map_err(DomainError::from) - } - - async fn get_folder_path(&self, id: &str) -> Result { - FolderRepository::get_folder_storage_path(self, id).await.map_err(DomainError::from) - } - - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - offset: usize, - limit: usize, - include_total: bool - ) -> Result<(Vec, Option), DomainError> { - FolderRepository::list_folders_paginated(self, parent_id, offset, limit, include_total) - .await - .map_err(DomainError::from) - } -} - #[async_trait] impl FolderRepository for FolderFsRepository { - #[instrument(skip(self))] - async fn move_to_trash(&self, folder_id: &str) -> FolderRepositoryResult<()> { - // Use the private implementation from folder_fs_repository_trash.rs - self._trash_move_to_trash(folder_id).await - } - - #[instrument(skip(self))] - async fn restore_from_trash(&self, folder_id: &str, original_path: &str) -> FolderRepositoryResult<()> { - // Use the private implementation from folder_fs_repository_trash.rs - self._trash_restore_from_trash(folder_id, original_path).await - } - - #[instrument(skip(self))] - async fn delete_folder_permanently(&self, folder_id: &str) -> FolderRepositoryResult<()> { - // Use the private implementation from folder_fs_repository_trash.rs - self._trash_delete_folder_permanently(folder_id).await - } - async fn create_folder(&self, name: String, parent_id: Option) -> FolderRepositoryResult { + async fn create_folder(&self, name: String, parent_id: Option) -> Result { // Get the parent folder path (if any) let parent_storage_path = match &parent_id { Some(id) => { - match self.get_folder_storage_path(id).await { + match self._get_folder_storage_path(id).await { Ok(path) => { tracing::info!("Using folder path: {:?} for parent_id: {:?}", path.to_string(), id); Some(path) }, Err(e) => { tracing::error!("Error getting parent folder: {}", e); - return Err(e); + return Err(DomainError::from(e)); }, } }, @@ -370,27 +269,28 @@ impl FolderRepository for FolderFsRepository { tracing::info!("Creating folder at path: {:?}", folder_storage_path.to_string()); // Check if folder already exists - if self.folder_exists_at_storage_path(&folder_storage_path).await? { - return Err(FolderRepositoryError::AlreadyExists(folder_storage_path.to_string())); + if self.check_folder_exists_at_storage_path(&folder_storage_path).await.map_err(DomainError::from)? { + return Err(DomainError::already_exists("Folder", folder_storage_path.to_string())); } // Create the physical directory let abs_path = self.resolve_storage_path(&folder_storage_path); self.create_directory(&abs_path).await - .map_err(FolderRepositoryError::IoError)?; + .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; // Create and return the folder entity with a persisted ID - let id = self.id_mapping_service.get_or_create_id(&folder_storage_path).await?; + let id = self.id_mapping_service.get_or_create_id(&folder_storage_path).await + .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; let folder = self.create_folder_entity( - id.clone(), // Clone for logging - name.clone(), // Clone name for logging - folder_storage_path.clone(), // Clone for logging - parent_id.clone(), // Clone for logging + id.clone(), + name.clone(), + folder_storage_path.clone(), + parent_id.clone(), None, None, - ).await?; + ).await.map_err(DomainError::from)?; - // Ensure ID mapping is persisted - this is critical for later retrieval + // Ensure ID mapping is persisted let save_result = self.id_mapping_service.save_changes().await; if let Err(e) = &save_result { tracing::error!("Failed to save ID mapping for folder {}: {}", id, e); @@ -404,38 +304,36 @@ impl FolderRepository for FolderFsRepository { Ok(folder) } - async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult { + async fn get_folder(&self, id: &str) -> Result { tracing::debug!("Looking for folder with ID: {}", id); // Find path by ID using the mapping service - let storage_path = self.id_mapping_service.get_path_by_id(id).await - .map_err(FolderRepositoryError::from)?; + let storage_path = self.id_mapping_service.get_path_by_id(id).await?; // Check if folder exists physically let abs_path = self.resolve_storage_path(&storage_path); if !abs_path.exists() || !abs_path.is_dir() { tracing::error!("Folder not found at path: {}", abs_path.display()); - return Err(FolderRepositoryError::NotFound(format!("Folder {} not found at {}", id, storage_path.to_string()))); + return Err(DomainError::not_found("Folder", format!("Folder {} not found at {}", id, storage_path.to_string()))); } // Get folder metadata - let (created_at, modified_at) = self.get_folder_metadata(&abs_path).await?; + let (created_at, modified_at) = self.get_folder_metadata(&abs_path).await.map_err(DomainError::from)?; // Get folder name from the storage path let name = match storage_path.file_name() { Some(name) => name, None => { tracing::error!("Invalid folder path: {}", storage_path.to_string()); - return Err(FolderRepositoryError::InvalidPath(storage_path.to_string())); + return Err(DomainError::validation_error(format!("Invalid path: {}", storage_path.to_string()))); } }; // Determine parent ID if any let parent = storage_path.parent(); let parent_id: Option = if parent.is_none() || parent.as_ref().unwrap().is_empty() { - None // Root folder + None } else { - // Try to get the parent ID from the mapping service match self.id_mapping_service.get_or_create_id(parent.as_ref().unwrap()).await { Ok(pid) => Some(pid), Err(_) => None, @@ -450,32 +348,31 @@ impl FolderRepository for FolderFsRepository { parent_id, Some(created_at), Some(modified_at), - ).await?; + ).await.map_err(DomainError::from)?; Ok(folder) } - async fn get_folder_by_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult { + async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { // Check if the physical directory exists let abs_path = self.resolve_storage_path(storage_path); if !abs_path.exists() || !abs_path.is_dir() { - return Err(FolderRepositoryError::NotFound(storage_path.to_string())); + return Err(DomainError::not_found("Folder", storage_path.to_string())); } // Extract folder name from storage path let name = match storage_path.file_name() { Some(name) => name, None => { - return Err(FolderRepositoryError::InvalidPath(storage_path.to_string())); + return Err(DomainError::validation_error(format!("Invalid path: {}", storage_path.to_string()))); } }; // Determine parent ID if any let parent = storage_path.parent(); let parent_id: Option = if parent.is_none() || parent.as_ref().unwrap().is_empty() { - None // Root folder + None } else { - // Try to get the parent ID from the mapping service match self.id_mapping_service.get_or_create_id(parent.as_ref().unwrap()).await { Ok(pid) => Some(pid), Err(_) => None, @@ -483,7 +380,7 @@ impl FolderRepository for FolderFsRepository { }; // Get folder metadata - let (created_at, modified_at) = self.get_folder_metadata(&abs_path).await?; + let (created_at, modified_at) = self.get_folder_metadata(&abs_path).await.map_err(DomainError::from)?; // Get or create an ID for this path let id = self.id_mapping_service.get_or_create_id(storage_path).await?; @@ -497,7 +394,7 @@ impl FolderRepository for FolderFsRepository { parent_id, Some(created_at), Some(modified_at), - ).await?; + ).await.map_err(DomainError::from)?; // Ensure ID mapping is persisted self.id_mapping_service.save_changes().await?; @@ -505,8 +402,8 @@ impl FolderRepository for FolderFsRepository { Ok(folder) } - async fn list_folders(&self, parent_id: Option<&str>) -> FolderRepositoryResult> { - use futures::stream::{StreamExt}; + async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { + use futures::stream::StreamExt; use tokio::time::{timeout, Duration}; tracing::info!("Listing folders in parent_id: {:?}", parent_id); @@ -514,7 +411,7 @@ impl FolderRepository for FolderFsRepository { // Get the parent storage path let parent_storage_path = match parent_id { Some(id) => { - match self.get_folder_storage_path(id).await { + match self._get_folder_storage_path(id).await { Ok(path) => { tracing::info!("Found parent folder with path: {:?}", path.to_string()); path @@ -538,21 +435,20 @@ impl FolderRepository for FolderFsRepository { return Ok(Vec::new()); } - // Read the directory with a timeout to avoid indefinite blocking + // Read the directory with a timeout let read_dir_timeout = Duration::from_secs(30); let read_dir_result = match timeout( read_dir_timeout, fs::read_dir(&abs_parent_path) ).await { - Ok(result) => result.map_err(FolderRepositoryError::IoError)?, + Ok(result) => result.map_err(|e| DomainError::internal_error("Folder", e.to_string()))?, Err(_) => { - return Err(FolderRepositoryError::Other( + return Err(DomainError::internal_error("Folder", format!("Timeout reading directory: {}", abs_parent_path.display()) )); } }; - // Process each entry sequentially to avoid async block type issues let mut folders = Vec::new(); let mut entries = tokio_stream::wrappers::ReadDirStream::new(read_dir_result); @@ -568,27 +464,22 @@ impl FolderRepository for FolderFsRepository { let metadata = match entry.metadata().await { Ok(m) => m, Err(err) => { - tracing::error!("Error getting metadata for {}: {}", - entry.path().display(), err); + tracing::error!("Error getting metadata for {}: {}", entry.path().display(), err); continue; } }; - // Skip if not a directory if !metadata.is_dir() { continue; } let folder_name = entry.file_name().to_string_lossy().to_string(); - - // Create the storage path for this folder let folder_storage_path = parent_storage_path.join(&folder_name); - // Try to get the folder by its storage path with timeout let get_folder_timeout = Duration::from_secs(5); let folder_result = timeout( get_folder_timeout, - self.get_folder_by_storage_path(&folder_storage_path) + self.get_folder_by_path(&folder_storage_path) ).await; match folder_result { @@ -609,7 +500,6 @@ impl FolderRepository for FolderFsRepository { } } - // Persist any new ID mappings that were created if let Err(e) = self.id_mapping_service.save_changes().await { tracing::error!("Failed to save ID mappings: {}", e); } @@ -624,21 +514,17 @@ impl FolderRepository for FolderFsRepository { offset: usize, limit: usize, include_total: bool - ) -> FolderRepositoryResult<(Vec, Option)> { + ) -> Result<(Vec, Option), DomainError> { use futures::stream::StreamExt; use tokio::time::{timeout, Duration}; tracing::info!("Listing folders in parent_id: {:?} with pagination (offset={}, limit={})", parent_id, offset, limit); - // Get the parent storage path let parent_storage_path = match parent_id { Some(id) => { - match self.get_folder_storage_path(id).await { - Ok(path) => { - tracing::info!("Found parent folder with path: {:?}", path.to_string()); - path - }, + match self._get_folder_storage_path(id).await { + Ok(path) => path, Err(e) => { tracing::error!("Error getting parent folder by ID: {}: {}", id, e); return Ok((Vec::new(), Some(0))); @@ -648,17 +534,12 @@ impl FolderRepository for FolderFsRepository { None => StoragePath::root(), }; - // Get the absolute folder path let abs_parent_path = self.resolve_storage_path(&parent_storage_path); - tracing::info!("Absolute parent path: {:?}", abs_parent_path); - // Ensure the directory exists if !abs_parent_path.exists() || !abs_parent_path.is_dir() { - tracing::error!("Directory does not exist or is not a directory: {:?}", abs_parent_path); return Ok((Vec::new(), Some(0))); } - // Get total count if requested let total_count = if include_total { match self.count_directory_items(&abs_parent_path).await { Ok(count) => Some(count), @@ -671,34 +552,29 @@ impl FolderRepository for FolderFsRepository { None }; - // Read the directory with a timeout to avoid indefinite blocking let read_dir_timeout = Duration::from_secs(30); let read_dir_result = match timeout( read_dir_timeout, fs::read_dir(&abs_parent_path) ).await { - Ok(result) => result.map_err(FolderRepositoryError::IoError)?, + Ok(result) => result.map_err(|e| DomainError::internal_error("Folder", e.to_string()))?, Err(_) => { - return Err(FolderRepositoryError::Other( + return Err(DomainError::internal_error("Folder", format!("Timeout reading directory: {}", abs_parent_path.display()) )); } }; - // Process entries sequentially to avoid async block typing issues let mut entries = tokio_stream::wrappers::ReadDirStream::new(read_dir_result); let mut folders = Vec::new(); let mut current_idx = 0; - // Loop through entries, applying pagination manually while let Some(entry_result) = entries.next().await { - // Skip entries before offset if current_idx < offset { current_idx += 1; continue; } - // Stop after reaching limit if folders.len() >= limit { break; } @@ -712,7 +588,6 @@ impl FolderRepository for FolderFsRepository { } }; - // Check if it's a directory let file_type = match entry.file_type().await { Ok(ft) => ft, Err(e) => { @@ -727,7 +602,6 @@ impl FolderRepository for FolderFsRepository { continue; } - // Get the path and convert to StoragePath let path = entry.path(); let rel_path = match path.strip_prefix(&self.root_path) { Ok(rel) => StoragePath::from(rel.to_path_buf()), @@ -738,10 +612,9 @@ impl FolderRepository for FolderFsRepository { } }; - // Get the folder entity with timeout let folder_result = timeout( Duration::from_secs(10), - self.get_folder_by_storage_path(&rel_path) + self.get_folder_by_path(&rel_path) ).await; match folder_result { @@ -761,68 +634,54 @@ impl FolderRepository for FolderFsRepository { current_idx += 1; } - // Save ID mappings if !folders.is_empty() { if let Err(e) = self.id_mapping_service.save_changes().await { tracing::error!("Error saving ID mappings: {}", e); } } - tracing::info!("Found {} folders in paginated request", folders.len()); - Ok((folders, total_count)) } - async fn rename_folder(&self, id: &str, new_name: String) -> FolderRepositoryResult { - // Get the original folder - let original_folder = self.get_folder_by_id(id).await?; + async fn rename_folder(&self, id: &str, new_name: String) -> Result { + let original_folder = self.get_folder(id).await?; tracing::debug!("Renaming folder with ID: {}, Name: {}", id, original_folder.name()); - // Create an immutable new version of the folder with updated name let renamed_folder = original_folder.with_name(new_name) - .map_err(|e| FolderRepositoryError::ValidationError(e.to_string()))?; + .map_err(|e| DomainError::validation_error(e.to_string()))?; - // Check if target already exists - if self.folder_exists_at_storage_path(renamed_folder.storage_path()).await? { - return Err(FolderRepositoryError::AlreadyExists(renamed_folder.storage_path().to_string())); + if self.check_folder_exists_at_storage_path(renamed_folder.storage_path()).await.map_err(DomainError::from)? { + return Err(DomainError::already_exists("Folder", renamed_folder.storage_path().to_string())); } - // Rename the physical directory let abs_old_path = self.resolve_storage_path(original_folder.storage_path()); let abs_new_path = self.resolve_storage_path(renamed_folder.storage_path()); fs::rename(&abs_old_path, &abs_new_path).await - .map_err(FolderRepositoryError::IoError)?; + .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; - // Update the ID mapping - self.id_mapping_service.update_path(id, renamed_folder.storage_path()).await - .map_err(FolderRepositoryError::from)?; - - // Save the updated mappings + self.id_mapping_service.update_path(id, renamed_folder.storage_path()).await?; self.id_mapping_service.save_changes().await?; tracing::debug!("Folder renamed successfully: ID={}, New name={}", id, renamed_folder.name()); Ok(renamed_folder) } - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> FolderRepositoryResult { - // Get the original folder - let original_folder = self.get_folder_by_id(id).await?; + async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result { + let original_folder = self.get_folder(id).await?; tracing::debug!("Moving folder with ID: {}, Name: {}", id, original_folder.name()); - // If the target parent is the same as current, no need to move if original_folder.parent_id() == new_parent_id { tracing::info!("Folder is already in the target parent, no need to move"); return Ok(original_folder); } - // Get the target parent path let target_parent_storage_path = match new_parent_id { Some(parent_id) => { - match self.get_folder_storage_path(parent_id).await { + match self._get_folder_storage_path(parent_id).await { Ok(path) => Some(path), Err(e) => { - return Err(FolderRepositoryError::Other( + return Err(DomainError::internal_error("Folder", format!("Could not get target folder: {}", e) )); } @@ -831,68 +690,50 @@ impl FolderRepository for FolderFsRepository { None => None }; - // Create an immutable new version of the folder with updated parent let new_parent_id_option = new_parent_id.map(String::from); let moved_folder = original_folder.with_parent(new_parent_id_option, target_parent_storage_path) - .map_err(|e| FolderRepositoryError::ValidationError(e.to_string()))?; + .map_err(|e| DomainError::validation_error(e.to_string()))?; - // Check if target already exists - if self.folder_exists_at_storage_path(moved_folder.storage_path()).await? { - return Err(FolderRepositoryError::AlreadyExists( + if self.check_folder_exists_at_storage_path(moved_folder.storage_path()).await.map_err(DomainError::from)? { + return Err(DomainError::already_exists("Folder", format!("Folder already exists at destination: {}", moved_folder.storage_path().to_string()) )); } - // Move the physical directory let old_abs_path = self.resolve_storage_path(original_folder.storage_path()); let new_abs_path = self.resolve_storage_path(moved_folder.storage_path()); - // Ensure the target directory exists if let Some(parent) = new_abs_path.parent() { fs::create_dir_all(parent).await - .map_err(FolderRepositoryError::IoError)?; + .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; } - // Move the directory physically (efficient rename operation) fs::rename(&old_abs_path, &new_abs_path).await - .map_err(FolderRepositoryError::IoError)?; + .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; - tracing::info!("Folder moved successfully from {:?} to {:?}", old_abs_path, new_abs_path); - - // Update the ID mapping - self.id_mapping_service.update_path(id, moved_folder.storage_path()).await - .map_err(FolderRepositoryError::from)?; - - // Save the updated mappings + self.id_mapping_service.update_path(id, moved_folder.storage_path()).await?; self.id_mapping_service.save_changes().await?; tracing::debug!("Folder moved successfully: ID={}, New path={:?}", id, moved_folder.storage_path().to_string()); Ok(moved_folder) } - async fn delete_folder(&self, id: &str) -> FolderRepositoryResult<()> { + async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { use tokio::time::{timeout, Duration}; - // Get the folder first to check if it exists - let folder = self.get_folder_by_id(id).await?; + let folder = self.get_folder(id).await?; let folder_name = folder.name().to_string(); let storage_path = folder.storage_path().clone(); tracing::info!("Deleting folder with ID: {}, Name: {}", id, folder_name); - // Para carpetas grandes, eliminar puede tomar tiempo - // Lo manejamos en un task separado para no bloquear let abs_path = self.resolve_storage_path(&storage_path); - - // Si la carpeta contiene muchos archivos, remove_dir_all puede tardar - // usamos tokio::spawn para hacerlo en un task separado let path_for_display = abs_path.display().to_string(); let path_for_deletion = abs_path.clone(); let delete_task = tokio::spawn(async move { tracing::debug!("Starting removal of folder: {}", path_for_display); - // Verificar si la carpeta existe y tiene muchas entradas let path_for_counting = path_for_deletion.clone(); let entry_count = tokio::task::spawn_blocking(move || { let mut count = 0; @@ -900,19 +741,15 @@ impl FolderRepository for FolderFsRepository { for _ in entries { count += 1; if count > 1000 { - break; // Solo necesitamos saber si es grande + break; } } } count }).await.unwrap_or(0); - // Para carpetas muy grandes, usar remove_dir_all puede causar bloqueos - // Para carpetas pequeñas, usamos la versión asíncrona estándar if entry_count > 1000 { tracing::info!("Large folder detected with >1000 entries, using blocking removal"); - - // Para carpetas muy grandes, usamos spawn_blocking para no bloquear el runtime de tokio let path_for_large_removal = path_for_deletion.clone(); tokio::task::spawn_blocking(move || { if let Err(e) = std::fs::remove_dir_all(&path_for_large_removal) { @@ -930,13 +767,11 @@ impl FolderRepository for FolderFsRepository { )) }) } else { - tracing::debug!("Using async removal for folder with {} entries", entry_count); fs::remove_dir_all(&path_for_deletion).await } }); - // Esperar a que termine la eliminación con timeout - const DELETE_TIMEOUT_SECS: u64 = 60; // 1 minuto máximo para eliminar + const DELETE_TIMEOUT_SECS: u64 = 60; let delete_result = timeout( Duration::from_secs(DELETE_TIMEOUT_SECS), @@ -948,29 +783,21 @@ impl FolderRepository for FolderFsRepository { match task_result { Ok(fs_result) => { if let Err(e) = fs_result { - return Err(FolderRepositoryError::IoError(e)); + return Err(DomainError::internal_error("Folder", e.to_string())); } }, Err(join_err) => { - return Err(FolderRepositoryError::Other( + return Err(DomainError::internal_error("Folder", format!("Task panicked during folder deletion: {}", join_err) )); } } }, Err(_) => { - // El timeout ocurrió, pero la tarea sigue ejecutándose en segundo plano tracing::warn!("Timeout waiting for folder deletion, continuing with ID removal"); - // No retornamos error, continuamos con la eliminación del ID } } - // Incluso si la eliminación física puede estar en progreso (timeout), - // procedemos a eliminar la entrada del mapping - // En el peor caso, si la eliminación física falla pero el ID se elimina, - // la carpeta se quedará huérfana, pero no afectará al sistema - - // Remove the ID mapping con timeout const MAPPING_TIMEOUT_SECS: u64 = 5; let remove_id_result = timeout( Duration::from_secs(MAPPING_TIMEOUT_SECS), @@ -978,46 +805,37 @@ impl FolderRepository for FolderFsRepository { ).await; match remove_id_result { - Ok(result) => result.map_err(FolderRepositoryError::from)?, + Ok(result) => result?, Err(_) => { - return Err(FolderRepositoryError::Other( + return Err(DomainError::internal_error("Folder", "Timeout removing folder ID from mapping".to_string() )); } } - // Save the updated mappings (asíncrono, no esperamos) let _ = self.id_mapping_service.save_changes().await; tracing::info!("Folder deleted successfully: ID={}, Name={}", id, folder_name); Ok(()) } - async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult { - self.check_folder_exists_at_storage_path(storage_path).await + async fn folder_exists(&self, storage_path: &StoragePath) -> Result { + self.check_folder_exists_at_storage_path(storage_path).await.map_err(DomainError::from) } - async fn get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult { - // Use the ID mapping service to get the storage path - let storage_path = self.id_mapping_service.get_path_by_id(id).await - .map_err(FolderRepositoryError::from)?; - - Ok(storage_path) + async fn get_folder_path(&self, id: &str) -> Result { + self._get_folder_storage_path(id).await.map_err(DomainError::from) } - - // Legacy method implementations - - async fn folder_exists(&self, path: &std::path::PathBuf) -> FolderRepositoryResult { - let abs_path = self.resolve_legacy_path(path); - Ok(abs_path.exists() && abs_path.is_dir()) + + async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { + self._trash_move_to_trash(folder_id).await.map_err(DomainError::from) } - - async fn get_folder_by_path(&self, path: &std::path::PathBuf) -> FolderRepositoryResult { - // Convert PathBuf to StoragePath - let path_str = path.to_string_lossy().to_string(); - let storage_path = StoragePath::from_string(&path_str); - - // Use the new method - self.get_folder_by_storage_path(&storage_path).await + + async fn restore_from_trash(&self, folder_id: &str, original_path: &str) -> Result<(), DomainError> { + self._trash_restore_from_trash(folder_id, original_path).await.map_err(DomainError::from) + } + + async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> { + self._trash_delete_folder_permanently(folder_id).await.map_err(DomainError::from) } } \ No newline at end of file diff --git a/src/infrastructure/repositories/folder_fs_repository_trash.rs b/src/infrastructure/repositories/folder_fs_repository_trash.rs index dc094bd3..0f71be0a 100644 --- a/src/infrastructure/repositories/folder_fs_repository_trash.rs +++ b/src/infrastructure/repositories/folder_fs_repository_trash.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use tokio::fs; use tracing::{debug, error}; -use crate::domain::repositories::folder_repository::FolderRepositoryResult; +use crate::infrastructure::repositories::repository_errors::FolderRepositoryResult; use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository; // Este archivo contiene la implementación de los métodos relacionados con la papelera @@ -22,7 +22,7 @@ impl FolderFsRepository { // Asegurarse que el directorio de la papelera existe if !trash_dir.exists() { fs::create_dir_all(&trash_dir).await - .map_err(|e| FolderRepositoryError::IoError(e))?; + .map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; } // Crear una ruta única para la carpeta en la papelera @@ -72,7 +72,7 @@ impl FolderFsRepository { }, Err(e) => { error!("Error moviendo carpeta a papelera: {}", e); - Err(FolderRepositoryError::IoError(e)) + Err(FolderRepositoryError::StorageError(e.to_string())) } } } @@ -99,7 +99,7 @@ impl FolderFsRepository { fs::create_dir_all(parent).await .map_err(|e| { error!("Error creando directorio padre para restauración: {}", e); - FolderRepositoryError::IoError(e) + FolderRepositoryError::StorageError(e.to_string()) })?; } } @@ -119,7 +119,7 @@ impl FolderFsRepository { }, Err(e) => { error!("Error restaurando carpeta: {}", e); - Err(FolderRepositoryError::IoError(e)) + Err(FolderRepositoryError::StorageError(e.to_string())) } } } @@ -147,7 +147,7 @@ impl FolderFsRepository { error!("Error eliminando carpeta permanentemente: {}", e); // No reportar error si la carpeta ya no existe if e.kind() != std::io::ErrorKind::NotFound { - return Err(FolderRepositoryError::IoError(e)); + return Err(FolderRepositoryError::StorageError(e.to_string())); } } } @@ -165,4 +165,4 @@ impl FolderFsRepository { } // Re-exportaciones necesarias para el compilador -use crate::domain::repositories::folder_repository::FolderRepositoryError; \ No newline at end of file +use crate::infrastructure::repositories::repository_errors::FolderRepositoryError; \ No newline at end of file diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index db64b897..95cafcfb 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,14 +1,13 @@ -pub mod file_fs_repository; pub mod folder_fs_repository; pub mod parallel_file_processor; +pub mod repository_errors; -// Nuevos repositorios refactorizados -pub mod file_metadata_manager; -pub mod file_path_resolver; +// Repositorios CQRS (Read/Write) + composite pub mod file_fs_read_repository; pub mod file_fs_write_repository; +pub mod composite_file_repository; + pub mod trash_fs_repository; -pub mod file_fs_repository_trash; pub mod folder_fs_repository_trash; pub mod share_fs_repository; @@ -16,8 +15,7 @@ pub mod share_fs_repository; pub mod pg; // Re-exportar para facilitar acceso -pub use file_metadata_manager::FileMetadataManager; -pub use file_path_resolver::FilePathResolver; pub use file_fs_read_repository::FileFsReadRepository; pub use file_fs_write_repository::FileFsWriteRepository; +pub use composite_file_repository::CompositeFileRepository; pub use pg::{UserPgRepository, SessionPgRepository}; diff --git a/src/infrastructure/repositories/parallel_file_processor.rs b/src/infrastructure/repositories/parallel_file_processor.rs index 6fd125b1..06612576 100644 --- a/src/infrastructure/repositories/parallel_file_processor.rs +++ b/src/infrastructure/repositories/parallel_file_processor.rs @@ -10,7 +10,7 @@ use tracing::{info, debug, error}; use bytes::{Bytes, BytesMut}; use crate::common::config::AppConfig; -use crate::domain::repositories::file_repository::FileRepositoryError; +use crate::infrastructure::repositories::repository_errors::FileRepositoryError; use crate::infrastructure::services::buffer_pool::BufferPool; /// Structure for the byte range to process @@ -172,7 +172,7 @@ impl ParallelFileProcessor { pub async fn read_file_parallel(&self, file_path: &PathBuf) -> Result, FileRepositoryError> { // Get file size let metadata = tokio::fs::metadata(file_path).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; let file_size = metadata.len(); @@ -201,17 +201,17 @@ impl ParallelFileProcessor { if buffer.capacity() < file_size as usize { debug!("Buffer from pool too small ({}), using standard read", buffer.capacity()); let content = tokio::fs::read(file_path).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; return Ok(content); } // Use memory buffer from the pool let mut file = File::open(file_path).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; let read_size = file.read(buffer.as_mut_slice()).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; buffer.set_used(read_size); @@ -221,7 +221,7 @@ impl ParallelFileProcessor { } else { // Standard implementation without pool let content = tokio::fs::read(file_path).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; return Ok(content); } @@ -241,7 +241,7 @@ impl ParallelFileProcessor { // Open file once and share it let file = Arc::new(File::open(file_path).await - .map_err(FileRepositoryError::IoError)?); + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?); // Reference to BytesMut pool let bytes_pool = self.bytes_pool.clone(); @@ -312,7 +312,7 @@ impl ParallelFileProcessor { Ok(Ok(())) => {}, Ok(Err(e)) => { error!("Error in chunk {}: {}", i, e); - return Err(FileRepositoryError::IoError(e)); + return Err(FileRepositoryError::StorageError(e.to_string())); }, Err(e) => { error!("Task error in chunk {}: {}", i, e); @@ -347,7 +347,7 @@ impl ParallelFileProcessor { // Standard implementation (buffer pooling offers no advantages for simple writing) tokio::fs::write(file_path, content).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; return Ok(()); } @@ -358,7 +358,7 @@ impl ParallelFileProcessor { // Create file (we don't use Mutex to reduce contention) let file = File::create(file_path).await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; // Convert content to Bytes (single copy step) let content_bytes = Bytes::copy_from_slice(content); @@ -369,7 +369,7 @@ impl ParallelFileProcessor { // Process chunks in parallel for chunk in chunks { let file_clone = file.try_clone().await - .map_err(FileRepositoryError::IoError)?; + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; let semaphore_clone = self.concurrency_limiter.clone(); // Create Bytes slice (doesn't copy data, only references) @@ -406,7 +406,7 @@ impl ParallelFileProcessor { Ok(Ok(())) => {}, Ok(Err(e)) => { error!("Error in chunk {}: {}", i, e); - return Err(FileRepositoryError::IoError(e)); + return Err(FileRepositoryError::StorageError(e.to_string())); }, Err(e) => { error!("Task error in chunk {}: {}", i, e); @@ -417,7 +417,7 @@ impl ParallelFileProcessor { // Ensure everything has been written correctly let mut file_handle = file; - file_handle.flush().await.map_err(FileRepositoryError::IoError)?; + file_handle.flush().await.map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; info!("Successfully wrote file of {}MB in parallel with optimized Bytes", file_size / (1024 * 1024)); Ok(()) @@ -427,6 +427,7 @@ impl ParallelFileProcessor { #[cfg(test)] mod tests { use super::*; + use bytes::BufMut; use tempfile::tempdir; #[tokio::test] diff --git a/src/infrastructure/repositories/pg/address_book_pg_repository.rs b/src/infrastructure/repositories/pg/address_book_pg_repository.rs index 4bae9803..23a18d3e 100644 --- a/src/infrastructure/repositories/pg/address_book_pg_repository.rs +++ b/src/infrastructure/repositories/pg/address_book_pg_repository.rs @@ -27,28 +27,28 @@ impl AddressBookRepository for AddressBookPgRepository { RETURNING id, name, owner_id, description, color, is_public, created_at, updated_at "# ) - .bind(address_book.id) - .bind(&address_book.name) - .bind(&address_book.owner_id) - .bind(&address_book.description) - .bind(&address_book.color) - .bind(address_book.is_public) - .bind(address_book.created_at) - .bind(address_book.updated_at) + .bind(address_book.id()) + .bind(address_book.name()) + .bind(address_book.owner_id()) + .bind(address_book.description()) + .bind(address_book.color()) + .bind(address_book.is_public()) + .bind(address_book.created_at()) + .bind(address_book.updated_at()) .fetch_one(&*self.pool) .await .map_err(|e| DomainError::database_error(format!("Failed to create address book: {}", e)))?; - Ok(AddressBook { - id: row.get("id"), - name: row.get("name"), - owner_id: row.get("owner_id"), - description: row.get("description"), - color: row.get("color"), - is_public: row.get("is_public"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) + Ok(AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + )) } async fn update_address_book(&self, address_book: AddressBook) -> AddressBookRepositoryResult { @@ -61,26 +61,26 @@ impl AddressBookRepository for AddressBookPgRepository { RETURNING id, name, owner_id, description, color, is_public, created_at, updated_at "# ) - .bind(&address_book.name) - .bind(&address_book.description) - .bind(&address_book.color) - .bind(address_book.is_public) + .bind(address_book.name()) + .bind(address_book.description()) + .bind(address_book.color()) + .bind(address_book.is_public()) .bind(now) - .bind(address_book.id) + .bind(address_book.id()) .fetch_one(&*self.pool) .await .map_err(|e| DomainError::database_error(format!("Failed to update address book: {}", e)))?; - Ok(AddressBook { - id: row.get("id"), - name: row.get("name"), - owner_id: row.get("owner_id"), - description: row.get("description"), - color: row.get("color"), - is_public: row.get("is_public"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) + Ok(AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + )) } async fn delete_address_book(&self, id: &Uuid) -> AddressBookRepositoryResult<()> { @@ -111,16 +111,16 @@ impl AddressBookRepository for AddressBookPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get address book by id: {}", e)))?; - let result = maybe_row.map(|row| AddressBook { - id: row.get("id"), - name: row.get("name"), - owner_id: row.get("owner_id"), - description: row.get("description"), - color: row.get("color"), - is_public: row.get("is_public"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }); + let result = maybe_row.map(|row| AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + )); Ok(result) } @@ -140,16 +140,16 @@ impl AddressBookRepository for AddressBookPgRepository { .map_err(|e| DomainError::database_error(format!("Failed to get address books by owner: {}", e)))?; let result = rows.into_iter() - .map(|row| AddressBook { - id: row.get("id"), - name: row.get("name"), - owner_id: row.get("owner_id"), - description: row.get("description"), - color: row.get("color"), - is_public: row.get("is_public"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) + .map(|row| AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + )) .collect(); Ok(result) @@ -171,16 +171,16 @@ impl AddressBookRepository for AddressBookPgRepository { .map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?; let result = rows.into_iter() - .map(|row| AddressBook { - id: row.get("id"), - name: row.get("name"), - owner_id: row.get("owner_id"), - description: row.get("description"), - color: row.get("color"), - is_public: row.get("is_public"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) + .map(|row| AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + )) .collect(); Ok(result) @@ -200,16 +200,16 @@ impl AddressBookRepository for AddressBookPgRepository { .map_err(|e| DomainError::database_error(format!("Failed to get public address books: {}", e)))?; let result = rows.into_iter() - .map(|row| AddressBook { - id: row.get("id"), - name: row.get("name"), - owner_id: row.get("owner_id"), - description: row.get("description"), - color: row.get("color"), - is_public: row.get("is_public"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) + .map(|row| AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + )) .collect(); Ok(result) diff --git a/src/infrastructure/repositories/pg/contact_pg_repository.rs b/src/infrastructure/repositories/pg/contact_pg_repository.rs index 8190dbed..ca3f3833 100644 --- a/src/infrastructure/repositories/pg/contact_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_pg_repository.rs @@ -23,9 +23,9 @@ impl ContactPgRepository { impl ContactRepository for ContactPgRepository { async fn create_contact(&self, contact: Contact) -> ContactRepositoryResult { // Convert domain entities to persistence DTOs for JSONB serialization - let email_dtos = emails_to_persistence(&contact.email); - let phone_dtos = phones_to_persistence(&contact.phone); - let address_dtos = addresses_to_persistence(&contact.address); + let email_dtos = emails_to_persistence(contact.email()); + let phone_dtos = phones_to_persistence(contact.phone()); + let address_dtos = addresses_to_persistence(contact.address()); let email_json = serde_json::to_value(&email_dtos).unwrap_or(JsonValue::Null); let phone_json = serde_json::to_value(&phone_dtos).unwrap_or(JsonValue::Null); @@ -48,26 +48,26 @@ impl ContactRepository for ContactPgRepository { birthday, anniversary, vcard, etag, created_at, updated_at "# ) - .bind(contact.id) - .bind(contact.address_book_id) - .bind(&contact.uid) - .bind(&contact.full_name) - .bind(&contact.first_name) - .bind(&contact.last_name) - .bind(&contact.nickname) + .bind(contact.id()) + .bind(contact.address_book_id()) + .bind(contact.uid()) + .bind(contact.full_name_owned()) + .bind(contact.first_name_owned()) + .bind(contact.last_name_owned()) + .bind(contact.nickname_owned()) .bind(email_json) .bind(phone_json) .bind(address_json) - .bind(&contact.organization) - .bind(&contact.title) - .bind(&contact.notes) - .bind(&contact.photo_url) - .bind(contact.birthday) - .bind(contact.anniversary) - .bind(&contact.vcard) - .bind(&contact.etag) - .bind(contact.created_at) - .bind(contact.updated_at) + .bind(contact.organization_owned()) + .bind(contact.title_owned()) + .bind(contact.notes_owned()) + .bind(contact.photo_url_owned()) + .bind(contact.birthday().copied()) + .bind(contact.anniversary().copied()) + .bind(contact.vcard()) + .bind(contact.etag()) + .bind(*contact.created_at()) + .bind(*contact.updated_at()) .fetch_one(&*self.pool) .await .map_err(|e| DomainError::database_error(format!("Failed to create contact: {}", e)))?; @@ -80,9 +80,9 @@ impl ContactRepository for ContactPgRepository { async fn update_contact(&self, contact: Contact) -> ContactRepositoryResult { let now = Utc::now(); // Convert domain entities to persistence DTOs for JSONB serialization - let email_dtos = emails_to_persistence(&contact.email); - let phone_dtos = phones_to_persistence(&contact.phone); - let address_dtos = addresses_to_persistence(&contact.address); + let email_dtos = emails_to_persistence(contact.email()); + let phone_dtos = phones_to_persistence(contact.phone()); + let address_dtos = addresses_to_persistence(contact.address()); let email_json = serde_json::to_value(&email_dtos).unwrap_or(JsonValue::Null); let phone_json = serde_json::to_value(&phone_dtos).unwrap_or(JsonValue::Null); @@ -90,7 +90,7 @@ impl ContactRepository for ContactPgRepository { // Create a clone of the contact with the updated timestamp let mut updated_contact = contact.clone(); - updated_contact.updated_at = now; + updated_contact.set_updated_at(now); let _row = sqlx::query( r#" @@ -119,23 +119,23 @@ impl ContactRepository for ContactPgRepository { birthday, anniversary, vcard, etag, created_at, updated_at "# ) - .bind(&updated_contact.full_name) - .bind(&updated_contact.first_name) - .bind(&updated_contact.last_name) - .bind(&updated_contact.nickname) + .bind(updated_contact.full_name_owned()) + .bind(updated_contact.first_name_owned()) + .bind(updated_contact.last_name_owned()) + .bind(updated_contact.nickname_owned()) .bind(email_json) .bind(phone_json) .bind(address_json) - .bind(&updated_contact.organization) - .bind(&updated_contact.title) - .bind(&updated_contact.notes) - .bind(&updated_contact.photo_url) - .bind(updated_contact.birthday) - .bind(updated_contact.anniversary) - .bind(&updated_contact.vcard) - .bind(&updated_contact.etag) + .bind(updated_contact.organization_owned()) + .bind(updated_contact.title_owned()) + .bind(updated_contact.notes_owned()) + .bind(updated_contact.photo_url_owned()) + .bind(updated_contact.birthday().copied()) + .bind(updated_contact.anniversary().copied()) + .bind(updated_contact.vcard()) + .bind(updated_contact.etag()) .bind(now) - .bind(updated_contact.id) + .bind(updated_contact.id()) .fetch_one(&*self.pool) .await .map_err(|e| DomainError::database_error(format!("Failed to update contact: {}", e)))?; diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs new file mode 100644 index 00000000..0e78c3f2 --- /dev/null +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use tracing::error; +use uuid::Uuid; + +use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::application::ports::favorites_ports::FavoritesRepositoryPort; +use crate::common::errors::{Result, DomainError, ErrorKind}; + +/// Implementación PostgreSQL del puerto de persistencia de favoritos. +pub struct FavoritesPgRepository { + db_pool: Arc, +} + +impl FavoritesPgRepository { + pub fn new(db_pool: Arc) -> Self { + Self { db_pool } + } +} + +#[async_trait] +impl FavoritesRepositoryPort for FavoritesPgRepository { + async fn get_favorites(&self, user_id: &str) -> Result> { + let user_uuid = Uuid::parse_str(user_id)?; + + let rows = sqlx::query( + r#" + SELECT + id::TEXT AS "id", + user_id::TEXT AS "user_id", + item_id AS "item_id", + item_type AS "item_type", + created_at AS "created_at" + FROM auth.user_favorites + WHERE user_id = $1::TEXT + ORDER BY created_at DESC + "#, + ) + .bind(user_uuid) + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error fetching favorites: {}", e); + DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to fetch favorites: {}", e)) + })?; + + let favorites = rows + .iter() + .map(|row| FavoriteItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + created_at: row.get("created_at"), + }) + .collect(); + + Ok(favorites) + } + + async fn add_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { + let user_uuid = Uuid::parse_str(user_id)?; + + sqlx::query( + r#" + INSERT INTO auth.user_favorites (user_id, item_id, item_type) + VALUES ($1::TEXT, $2, $3) + ON CONFLICT (user_id, item_id, item_type) DO NOTHING + "#, + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error adding favorite: {}", e); + DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to add to favorites: {}", e)) + })?; + + Ok(()) + } + + async fn remove_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { + let user_uuid = Uuid::parse_str(user_id)?; + + let result = sqlx::query( + r#" + DELETE FROM auth.user_favorites + WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3 + "#, + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error removing favorite: {}", e); + DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to remove from favorites: {}", e)) + })?; + + Ok(result.rows_affected() > 0) + } + + async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { + let user_uuid = Uuid::parse_str(user_id)?; + + let row = sqlx::query( + r#" + SELECT EXISTS ( + SELECT 1 FROM auth.user_favorites + WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3 + ) AS "is_favorite" + "#, + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .fetch_one(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error checking favorite status: {}", e); + DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to check favorite status: {}", e)) + })?; + + Ok(row.try_get("is_favorite").unwrap_or(false)) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 8b899e40..014be432 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -3,6 +3,8 @@ mod calendar_pg_repository; mod calendar_event_pg_repository; mod contact_pg_repository; mod contact_persistence_dto; +mod favorites_pg_repository; +mod recent_items_pg_repository; mod session_pg_repository; mod transaction_utils; mod user_pg_repository; @@ -12,5 +14,7 @@ pub use calendar_pg_repository::CalendarPgRepository; pub use calendar_event_pg_repository::CalendarEventPgRepository; pub use contact_pg_repository::ContactPgRepository; pub use contact_persistence_dto::*; +pub use favorites_pg_repository::FavoritesPgRepository; +pub use recent_items_pg_repository::RecentItemsPgRepository; pub use session_pg_repository::SessionPgRepository; pub use user_pg_repository::UserPgRepository; diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs new file mode 100644 index 00000000..42ee3159 --- /dev/null +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use tracing::error; +use uuid::Uuid; + +use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::ports::recent_ports::RecentItemsRepositoryPort; +use crate::common::errors::{Result, DomainError, ErrorKind}; + +/// Implementación PostgreSQL del puerto de persistencia de elementos recientes. +pub struct RecentItemsPgRepository { + db_pool: Arc, +} + +impl RecentItemsPgRepository { + pub fn new(db_pool: Arc) -> Self { + Self { db_pool } + } +} + +#[async_trait] +impl RecentItemsRepositoryPort for RecentItemsPgRepository { + async fn get_recent_items(&self, user_id: &str, limit: i32) -> Result> { + let user_uuid = Uuid::parse_str(user_id)?; + + let rows = sqlx::query( + r#" + SELECT + id::TEXT AS "id", + user_id::TEXT AS "user_id", + item_id AS "item_id", + item_type AS "item_type", + accessed_at AS "accessed_at" + FROM auth.user_recent_files + WHERE user_id = $1::TEXT + ORDER BY accessed_at DESC + LIMIT $2 + "#, + ) + .bind(user_uuid) + .bind(limit) + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error fetching recent items: {}", e); + DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to fetch recent items: {}", e)) + })?; + + let items = rows + .iter() + .map(|row| RecentItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + accessed_at: row.get("accessed_at"), + }) + .collect(); + + Ok(items) + } + + async fn upsert_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { + let user_uuid = Uuid::parse_str(user_id)?; + + sqlx::query( + r#" + INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at) + VALUES ($1::TEXT, $2, $3, CURRENT_TIMESTAMP) + ON CONFLICT (user_id, item_id, item_type) + DO UPDATE SET accessed_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error upserting recent item access: {}", e); + DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to record item access: {}", e)) + })?; + + Ok(()) + } + + async fn remove_item(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { + let user_uuid = Uuid::parse_str(user_id)?; + + let result = sqlx::query( + r#" + DELETE FROM auth.user_recent_files + WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3 + "#, + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error removing recent item: {}", e); + DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to remove recent item: {}", e)) + })?; + + Ok(result.rows_affected() > 0) + } + + async fn clear_all(&self, user_id: &str) -> Result<()> { + let user_uuid = Uuid::parse_str(user_id)?; + + sqlx::query( + r#" + DELETE FROM auth.user_recent_files + WHERE user_id = $1::TEXT + "#, + ) + .bind(user_uuid) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error clearing recent items: {}", e); + DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to clear recent items: {}", e)) + })?; + + Ok(()) + } + + async fn prune(&self, user_id: &str, max_items: i32) -> Result<()> { + let user_uuid = Uuid::parse_str(user_id)?; + + sqlx::query( + r#" + DELETE FROM auth.user_recent_files + WHERE id IN ( + SELECT id FROM auth.user_recent_files + WHERE user_id = $1::TEXT + ORDER BY accessed_at DESC + OFFSET $2 + ) + "#, + ) + .bind(user_uuid) + .bind(max_items) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error pruning old recent items: {}", e); + DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to prune recent items: {}", e)) + })?; + + Ok(()) + } +} diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 78f564c6..f22affdd 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -66,8 +66,8 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.user_id()) .bind(session_clone.refresh_token()) .bind(session_clone.expires_at()) - .bind(&session_clone.ip_address) - .bind(&session_clone.user_agent) + .bind(session_clone.ip_address()) + .bind(session_clone.user_agent()) .bind(session_clone.created_at()) .bind(session_clone.is_revoked()) .execute(&mut **tx) @@ -120,16 +120,16 @@ impl SessionRepository for SessionPgRepository { .await .map_err(Self::map_sqlx_error)?; - Ok(Session { - id: row.get("id"), - user_id: row.get("user_id"), - refresh_token: row.get("refresh_token"), - expires_at: row.get("expires_at"), - ip_address: row.get("ip_address"), - user_agent: row.get("user_agent"), - created_at: row.get("created_at"), - revoked: row.get("revoked"), - }) + Ok(Session::from_raw( + row.get("id"), + row.get("user_id"), + row.get("refresh_token"), + row.get("expires_at"), + row.get("ip_address"), + row.get("user_agent"), + row.get("created_at"), + row.get("revoked"), + )) } /// Obtiene una sesión por token de actualización @@ -148,16 +148,16 @@ impl SessionRepository for SessionPgRepository { .await .map_err(Self::map_sqlx_error)?; - Ok(Session { - id: row.get("id"), - user_id: row.get("user_id"), - refresh_token: row.get("refresh_token"), - expires_at: row.get("expires_at"), - ip_address: row.get("ip_address"), - user_agent: row.get("user_agent"), - created_at: row.get("created_at"), - revoked: row.get("revoked"), - }) + Ok(Session::from_raw( + row.get("id"), + row.get("user_id"), + row.get("refresh_token"), + row.get("expires_at"), + row.get("ip_address"), + row.get("user_agent"), + row.get("created_at"), + row.get("revoked"), + )) } /// Obtiene todas las sesiones de un usuario @@ -179,16 +179,16 @@ impl SessionRepository for SessionPgRepository { let sessions = rows.into_iter() .map(|row| { - Session { - id: row.get("id"), - user_id: row.get("user_id"), - refresh_token: row.get("refresh_token"), - expires_at: row.get("expires_at"), - ip_address: row.get("ip_address"), - user_agent: row.get("user_agent"), - created_at: row.get("created_at"), - revoked: row.get("revoked"), - } + Session::from_raw( + row.get("id"), + row.get("user_id"), + row.get("refresh_token"), + row.get("expires_at"), + row.get("ip_address"), + row.get("user_agent"), + row.get("created_at"), + row.get("revoked"), + ) }) .collect(); diff --git a/src/infrastructure/repositories/repository_errors.rs b/src/infrastructure/repositories/repository_errors.rs new file mode 100644 index 00000000..bbb6448a --- /dev/null +++ b/src/infrastructure/repositories/repository_errors.rs @@ -0,0 +1,96 @@ +//! Infrastructure-layer error types for file and folder repository operations. +//! +//! These error types are used internally by the filesystem repository implementations +//! (FileFsReadRepository, FileFsWriteRepository, FolderFsRepository, etc.) to represent +//! errors that can occur during storage operations. They are converted to `DomainError` +//! at the port boundary before crossing into the application layer. + +use crate::common::errors::DomainError; + +/// Error types for file repository operations. +#[derive(Debug, thiserror::Error)] +pub enum FileRepositoryError { + #[error("File not found: {0}")] + NotFound(String), + + #[error("File already exists: {0}")] + AlreadyExists(String), + + #[error("Invalid file path: {0}")] + InvalidPath(String), + + #[error("Operation not supported: {0}")] + OperationNotSupported(String), + + #[error("Storage error: {0}")] + StorageError(String), + + #[error("Domain error: {0}")] + DomainError(#[from] DomainError), + + #[error("Other error: {0}")] + Other(String), +} + +pub type FileRepositoryResult = Result; + +/// Error types for folder repository operations. +#[derive(Debug, thiserror::Error)] +pub enum FolderRepositoryError { + #[error("Folder not found: {0}")] + NotFound(String), + + #[error("Folder already exists: {0}")] + AlreadyExists(String), + + #[error("Invalid folder path: {0}")] + InvalidPath(String), + + #[error("Operation not supported: {0}")] + OperationNotSupported(String), + + #[error("Storage error: {0}")] + StorageError(String), + + #[error("Validation error: {0}")] + ValidationError(String), + + #[error("Domain error: {0}")] + DomainError(#[from] DomainError), + + #[error("Other error: {0}")] + Other(String), +} + +pub type FolderRepositoryResult = Result; + +// ── Conversions to DomainError ── + +impl From for DomainError { + fn from(err: FileRepositoryError) -> Self { + match err { + FileRepositoryError::NotFound(id) => DomainError::not_found("File", id), + FileRepositoryError::AlreadyExists(path) => DomainError::already_exists("File", path), + FileRepositoryError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)), + FileRepositoryError::StorageError(msg) => DomainError::internal_error("File", format!("Storage error: {}", msg)), + FileRepositoryError::Other(msg) => DomainError::internal_error("File", msg), + FileRepositoryError::OperationNotSupported(msg) => DomainError::operation_not_supported("File", msg), + FileRepositoryError::DomainError(e) => e, + } + } +} + +impl From for DomainError { + fn from(err: FolderRepositoryError) -> Self { + match err { + FolderRepositoryError::NotFound(id) => DomainError::not_found("Folder", id), + FolderRepositoryError::AlreadyExists(path) => DomainError::already_exists("Folder", path), + FolderRepositoryError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)), + FolderRepositoryError::StorageError(msg) => DomainError::internal_error("Folder", format!("Storage error: {}", msg)), + FolderRepositoryError::ValidationError(msg) => DomainError::validation_error(msg), + FolderRepositoryError::Other(msg) => DomainError::internal_error("Folder", msg), + FolderRepositoryError::OperationNotSupported(msg) => DomainError::operation_not_supported("Folder", msg), + FolderRepositoryError::DomainError(e) => e, + } + } +} diff --git a/src/infrastructure/repositories/share_fs_repository.rs b/src/infrastructure/repositories/share_fs_repository.rs index c084f496..be6136fd 100644 --- a/src/infrastructure/repositories/share_fs_repository.rs +++ b/src/infrastructure/repositories/share_fs_repository.rs @@ -83,35 +83,35 @@ impl ShareFsRepository { record.permissions_reshare, ); - Share { - id: record.id.clone(), - item_id: record.item_id.clone(), + Share::from_raw( + record.id.clone(), + record.item_id.clone(), item_type, - token: record.token.clone(), - password_hash: record.password_hash.clone(), - expires_at: record.expires_at, + record.token.clone(), + record.password_hash.clone(), + record.expires_at, permissions, - created_at: record.created_at, - created_by: record.created_by.clone(), - access_count: record.access_count, - } + record.created_at, + record.created_by.clone(), + record.access_count, + ) } /// Convierte una entidad de dominio a un registro para el sistema de archivos fn to_record(&self, share: &Share) -> ShareRecord { ShareRecord { - id: share.id.clone(), - item_id: share.item_id.clone(), - item_type: share.item_type.to_string(), - token: share.token.clone(), - password_hash: share.password_hash.clone(), - expires_at: share.expires_at, - permissions_read: share.permissions.read, - permissions_write: share.permissions.write, - permissions_reshare: share.permissions.reshare, - created_at: share.created_at, - created_by: share.created_by.clone(), - access_count: share.access_count, + id: share.id().to_string(), + item_id: share.item_id().to_string(), + item_type: share.item_type().to_string(), + token: share.token().to_string(), + password_hash: share.password_hash().map(|s| s.to_string()), + expires_at: share.expires_at(), + permissions_read: share.permissions().read(), + permissions_write: share.permissions().write(), + permissions_reshare: share.permissions().reshare(), + created_at: share.created_at(), + created_by: share.created_by().to_string(), + access_count: share.access_count(), } } } @@ -123,7 +123,7 @@ impl ShareStoragePort for ShareFsRepository { .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; // Verifica si el enlace ya existe - let existing_index = shares.iter().position(|s| s.id == share.id); + let existing_index = shares.iter().position(|s| s.id == share.id()); let record = self.to_record(share); @@ -191,9 +191,9 @@ impl ShareStoragePort for ShareFsRepository { .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; // Busca el índice del enlace a actualizar - let index = shares.iter().position(|s| s.id == share.id) + let index = shares.iter().position(|s| s.id == share.id()) .ok_or_else(|| { - DomainError::not_found("Share", format!("Share with ID {} not found for update", share.id)) + DomainError::not_found("Share", format!("Share with ID {} not found for update", share.id())) })?; // Actualiza el registro diff --git a/src/infrastructure/repositories/trash_fs_repository.rs b/src/infrastructure/repositories/trash_fs_repository.rs index 3d41fbe6..ac32f7f6 100644 --- a/src/infrastructure/repositories/trash_fs_repository.rs +++ b/src/infrastructure/repositories/trash_fs_repository.rs @@ -194,32 +194,32 @@ impl TrashFsRepository { ))? .with_timezone(&Utc); - Ok(TrashedItem { + Ok(TrashedItem::from_raw( id, original_id, user_id, item_type, - name: entry.name, - original_path: entry.original_path, + entry.name, + entry.original_path, trashed_at, deletion_date, - }) + )) } /// Convierte una entidad TrashedItem a entrada JSON fn trashed_item_to_entry(&self, item: &TrashedItem) -> TrashedItemEntry { TrashedItemEntry { - id: item.id.to_string(), - original_id: item.original_id.to_string(), - user_id: item.user_id.to_string(), - item_type: match item.item_type { + id: item.id().to_string(), + original_id: item.original_id().to_string(), + user_id: item.user_id().to_string(), + item_type: match item.item_type() { TrashedItemType::File => "file".to_string(), TrashedItemType::Folder => "folder".to_string(), }, - name: item.name.clone(), - original_path: item.original_path.clone(), - trashed_at: item.trashed_at.to_rfc3339(), - deletion_date: item.deletion_date.to_rfc3339(), + name: item.name().to_string(), + original_path: item.original_path().to_string(), + trashed_at: item.trashed_at().to_rfc3339(), + deletion_date: item.deletion_date().to_rfc3339(), } } } @@ -228,10 +228,10 @@ impl TrashFsRepository { impl TrashRepository for TrashFsRepository { #[instrument(skip(self))] async fn add_to_trash(&self, item: &TrashedItem) -> Result<()> { - debug!("Añadiendo elemento a la papelera: id={}, user={}", item.id, item.user_id); + debug!("Añadiendo elemento a la papelera: id={}, user={}", item.id(), item.user_id()); // Aseguramos que existe el directorio de la papelera para este usuario - let user_trash_dir = self.trash_dir.join("files").join(item.user_id.to_string()); + let user_trash_dir = self.trash_dir.join("files").join(item.user_id().to_string()); debug!("User trash directory path: {}", user_trash_dir.display()); // Create the user-specific trash directory diff --git a/src/infrastructure/services/buffer_pool.rs b/src/infrastructure/services/buffer_pool.rs index 7c84fb2c..0569ce15 100644 --- a/src/infrastructure/services/buffer_pool.rs +++ b/src/infrastructure/services/buffer_pool.rs @@ -89,9 +89,11 @@ impl BufferPool { ) } - /// Obtiene un buffer del pool o crea uno nuevo si es necesario + /// Obtiene un buffer del pool o crea uno nuevo si es necesario. + /// This version takes an Arc to ensure the BorrowedBuffer keeps a proper + /// reference to the shared pool (not a clone). #[allow(unused_variables)] - pub async fn get_buffer(&self) -> BorrowedBuffer { + pub async fn get_buffer(self: &Arc) -> BorrowedBuffer { // Incrementar contador de gets { let mut stats = self.stats.lock().await; @@ -99,27 +101,31 @@ impl BufferPool { } // Control de concurrencia - // Usando el mecanismo RAII de Rust para gestión automática - // de recursos al finalizar la función - let _ = match self.limit.try_acquire() { - Ok(_permit) => _permit, // _ prefix para indicar que es intencional + // Acquire a semaphore permit. If none available, wait. + // We forget() the permit so it doesn't auto-release on drop. + // Instead, the permit is manually released in return_buffer/Drop via add_permits(1). + match self.limit.try_acquire() { + Ok(permit) => permit.forget(), Err(_) => { // No hay permisos disponibles, esperamos - let mut stats = self.stats.lock().await; - stats.waits += 1; - stats.max_buffers_reached += 1; - drop(stats); + { + let mut stats = self.stats.lock().await; + stats.waits += 1; + stats.max_buffers_reached += 1; + } debug!("Buffer pool: waiting for available buffer"); - let _permit = self.limit.acquire().await.expect("Semaphore should not be closed"); + let permit = self.limit.acquire().await.expect("Semaphore should not be closed"); debug!("Buffer pool: acquired buffer after waiting"); - _permit + permit.forget(); } }; // Intentar obtener un buffer existente del pool let mut pool_locked = self.pool.lock().await; + let pool_arc = Arc::clone(self); + if let Some(mut pooled_buffer) = pool_locked.pop_front() { // Verificar si el buffer ha expirado if pooled_buffer.last_used.elapsed() > self.buffer_ttl { @@ -137,7 +143,7 @@ impl BufferPool { BorrowedBuffer { buffer: vec![0; self.buffer_size], used_size: 0, - pool: Arc::new(self.clone()), + pool: pool_arc, return_to_pool: true, } } else { @@ -155,7 +161,7 @@ impl BufferPool { BorrowedBuffer { buffer: pooled_buffer.buffer, used_size: 0, - pool: Arc::new(self.clone()), + pool: pool_arc, return_to_pool: true, } } @@ -173,7 +179,7 @@ impl BufferPool { BorrowedBuffer { buffer: vec![0; self.buffer_size], used_size: 0, - pool: Arc::new(self.clone()), + pool: pool_arc, return_to_pool: true, } } @@ -185,6 +191,8 @@ impl BufferPool { if buffer.capacity() != self.buffer_size { debug!("Buffer pool: discarding buffer of wrong size: {} (expected {})", buffer.capacity(), self.buffer_size); + // Release the semaphore permit even if we discard the buffer + self.limit.add_permits(1); return; } @@ -202,6 +210,11 @@ impl BufferPool { // Actualizar estadísticas let mut stats = self.stats.lock().await; stats.returns += 1; + + // Release the semaphore permit so another caller can acquire a buffer + drop(pool_locked); + drop(stats); + self.limit.add_permits(1); } /// Limpia buffers expirados del pool @@ -331,9 +344,13 @@ impl Drop for BorrowedBuffer { let pool = self.pool.clone(); // Spawn del return para que el drop no bloquee + // return_buffer will release the semaphore permit tokio::spawn(async move { pool.return_buffer(buffer).await; }); + } else { + // Buffer not returned to pool, but we still need to release the semaphore permit + self.pool.limit.add_permits(1); } } } diff --git a/src/infrastructure/services/cache_manager.rs b/src/infrastructure/services/cache_manager.rs deleted file mode 100644 index 2d078831..00000000 --- a/src/infrastructure/services/cache_manager.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::time; -use futures::future::BoxFuture; -use tokio::sync::RwLock; - -/// Representación de metadatos en caché -#[derive(Debug, Clone)] -pub struct CachedMetadata { - /// Si el archivo o directorio existe - pub exists: bool, - /// Tamaño en bytes (para archivos) - pub size: Option, - /// Timestamp de creación - pub created_at: Option, - /// Timestamp de modificación - pub modified_at: Option, - /// Tiempo de expiración de la caché - expires_at: Instant, -} - -/// Estructura para gestionar la caché de metadatos de archivos y directorios -pub struct StorageCacheManager { - /// Caché de existencia y metadatos - cache: RwLock>, - /// TTL para entradas de archivos (milisegundos) - file_ttl_ms: u64, - /// TTL para entradas de directorios (milisegundos) - dir_ttl_ms: u64, - /// Tamaño máximo de caché - max_entries: usize, -} - -impl StorageCacheManager { - /// Crea una nueva instancia del gestor de caché - pub fn new(file_ttl_ms: u64, dir_ttl_ms: u64, max_entries: usize) -> Self { - Self { - cache: RwLock::new(HashMap::with_capacity(max_entries)), - file_ttl_ms, - dir_ttl_ms, - max_entries, - } - } - - /// Crea una instancia por defecto del gestor de caché - pub fn default() -> Self { - Self::new( - 60_000, // 1 minuto para archivos - 300_000, // 5 minutos para directorios - 10_000, // máximo 10,000 entradas - ) - } - - /// Verifica si un archivo o directorio existe en caché - pub async fn check_exists(&self, path: &PathBuf, _is_dir: bool) -> Result { - // Intentar obtener de la caché - if let Some(metadata) = self.get_cached_metadata(path).await { - return Ok(metadata.exists); - } - - // No está en caché - Err(()) - } - - /// Obtiene los metadatos de un path desde la caché - async fn get_cached_metadata(&self, path: &PathBuf) -> Option { - let cache = self.cache.read().await; - - if let Some(metadata) = cache.get(path) { - // Verificar si la entrada expiró - if Instant::now() < metadata.expires_at { - return Some(metadata.clone()); - } - } - - None - } - - /// Actualiza la caché con los metadatos de un path - pub async fn update_cache(&self, path: &PathBuf, exists: bool, size: Option, - created_at: Option, modified_at: Option, is_dir: bool) { - let mut cache = self.cache.write().await; - - // Si la caché está llena, eliminar entradas aleatorias antes de agregar - if cache.len() >= self.max_entries { - self.evict_entries(&mut cache, 100).await; - } - - // Determinar TTL basado en si es archivo o directorio - let ttl = if is_dir { - Duration::from_millis(self.dir_ttl_ms) - } else { - Duration::from_millis(self.file_ttl_ms) - }; - - // Crear metadatos y agregar a la caché - let metadata = CachedMetadata { - exists, - size, - created_at, - modified_at, - expires_at: Instant::now() + ttl, - }; - - cache.insert(path.clone(), metadata); - } - - /// Elimina entradas aleatorias de la caché cuando está llena - async fn evict_entries(&self, cache: &mut HashMap, count: usize) { - // Obtener las entradas más antiguas para eliminar - let mut entries: Vec<_> = cache.keys().cloned().collect(); - - // Limitar el número de entradas a eliminar - let to_remove = count.min(entries.len() / 10); - - if to_remove == 0 { - return; - } - - // Eliminar las primeras entradas (implementación simple) - entries.truncate(to_remove); - - for path in entries { - cache.remove(&path); - } - } - - /// Inicia una tarea de limpieza periódica - pub fn start_cleanup_task(cache_manager: Arc) -> BoxFuture<'static, ()> { - Box::pin(async move { - let interval = Duration::from_secs(60); // Ejecutar cada minuto - - loop { - time::sleep(interval).await; - - // Limpiar entradas expiradas - let now = Instant::now(); - let mut cache = cache_manager.cache.write().await; - - // Encontrar entradas expiradas - let expired: Vec<_> = cache - .iter() - .filter(|(_, metadata)| now > metadata.expires_at) - .map(|(path, _)| path.clone()) - .collect(); - - // Eliminar entradas expiradas - for path in expired { - cache.remove(&path); - } - - // Registrar estadísticas - let cache_size = cache.len(); - drop(cache); - - tracing::debug!("Cache cleanup completed. Entries remaining: {}", cache_size); - } - }) - } - - /// Invalida una entrada específica de la caché - pub async fn invalidate(&self, path: &PathBuf) { - let mut cache = self.cache.write().await; - cache.remove(path); - } - - /// Invalida todas las entradas de la caché relacionadas con una carpeta - pub async fn invalidate_folder(&self, folder_path: &PathBuf) { - let mut cache = self.cache.write().await; - - // Eliminar entradas que sean descendientes de la carpeta - let folder_str = folder_path.to_string_lossy().to_string(); - - // Encontrar entradas a eliminar - let to_remove: Vec<_> = cache - .keys() - .filter_map(|path| { - let path_str = path.to_string_lossy().to_string(); - if path_str.starts_with(&folder_str) { - Some(path.clone()) - } else { - None - } - }) - .collect(); - - // Eliminar las entradas - for path in to_remove { - cache.remove(&path); - } - } - - /// Obtiene el número actual de entradas en la caché - pub async fn cache_size(&self) -> usize { - let cache = self.cache.read().await; - cache.len() - } -} \ No newline at end of file diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index d2e139b5..2fdf0e9b 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -20,6 +20,15 @@ use tokio::fs::{self, File, OpenOptions}; use tokio::io::AsyncWriteExt; use tokio::sync::RwLock; use uuid::Uuid; +use async_trait::async_trait; + +use crate::application::ports::chunked_upload_ports::{ + ChunkedUploadPort, + CreateUploadResponseDto, + ChunkUploadResponseDto, + UploadStatusResponseDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; /// Minimum file size to use chunked upload (10MB) pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024; @@ -507,6 +516,93 @@ impl ChunkedUploadService { } } +// ─── Port implementation ───────────────────────────────────────────────────── + +#[async_trait] +impl ChunkedUploadPort for ChunkedUploadService { + async fn create_session( + &self, + filename: String, + folder_id: Option, + content_type: String, + total_size: u64, + chunk_size: Option, + ) -> Result { + let resp = self.create_session(filename, folder_id, content_type, total_size, chunk_size).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?; + Ok(CreateUploadResponseDto { + upload_id: resp.upload_id, + chunk_size: resp.chunk_size, + total_chunks: resp.total_chunks, + expires_at: resp.expires_at, + }) + } + + async fn upload_chunk( + &self, + upload_id: &str, + chunk_index: usize, + data: bytes::Bytes, + checksum: Option, + ) -> Result { + let resp = self.upload_chunk(upload_id, chunk_index, data, checksum).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?; + Ok(ChunkUploadResponseDto { + chunk_index: resp.chunk_index, + bytes_received: resp.bytes_received, + progress: resp.progress, + is_complete: resp.is_complete, + }) + } + + async fn get_status( + &self, + upload_id: &str, + ) -> Result { + let resp = self.get_status(upload_id).await + .map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))?; + Ok(UploadStatusResponseDto { + upload_id: resp.upload_id, + filename: resp.filename, + total_size: resp.total_size, + bytes_received: resp.bytes_received, + progress: resp.progress, + total_chunks: resp.total_chunks, + completed_chunks: resp.completed_chunks, + pending_chunks: resp.pending_chunks, + is_complete: resp.is_complete, + }) + } + + async fn complete_upload( + &self, + upload_id: &str, + ) -> Result<(PathBuf, String, Option, String, u64), DomainError> { + self.complete_upload(upload_id).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) + } + + async fn finalize_upload( + &self, + upload_id: &str, + ) -> Result<(), DomainError> { + self.finalize_upload(upload_id).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) + } + + async fn cancel_upload( + &self, + upload_id: &str, + ) -> Result<(), DomainError> { + self.cancel_upload(upload_id).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) + } + + fn should_use_chunked(&self, size: u64) -> bool { + ChunkedUploadService::should_use_chunked(size) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/infrastructure/services/compression_service.rs b/src/infrastructure/services/compression_service.rs index 94851e3e..eb6f4d19 100644 --- a/src/infrastructure/services/compression_service.rs +++ b/src/infrastructure/services/compression_service.rs @@ -9,6 +9,11 @@ use flate2::Compression; use flate2::read::GzEncoder as GzEncoderRead; use flate2::bufread::GzDecoder; +use crate::application::ports::compression_ports::{ + CompressionPort, + CompressionLevel as PortCompressionLevel, +}; +use crate::domain::errors::DomainError; use crate::infrastructure::services::buffer_pool::BufferPool; /// Nivel de compresión para ficheros @@ -254,7 +259,7 @@ impl CompressionService for GzipCompressionService { } // Compress collected data - match self.compress_data(&data, compression_level).await { + match CompressionService::compress_data(self, &data, compression_level).await { Ok(compressed) => { // Return compressed data as a single chunk yield Ok(Bytes::from(compressed)); @@ -293,7 +298,7 @@ impl CompressionService for GzipCompressionService { } // Decompress collected data - match self.decompress_data(&compressed_data).await { + match CompressionService::decompress_data(self, &compressed_data).await { Ok(decompressed) => { // Return decompressed data as a single chunk yield Ok(Bytes::from(decompressed)); @@ -345,6 +350,35 @@ impl CompressionService for GzipCompressionService { } } +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert application-layer CompressionLevel to infrastructure CompressionLevel. +impl From for CompressionLevel { + fn from(level: PortCompressionLevel) -> Self { + match level { + PortCompressionLevel::None => CompressionLevel::None, + PortCompressionLevel::Fast => CompressionLevel::Fast, + PortCompressionLevel::Default => CompressionLevel::Default, + PortCompressionLevel::Best => CompressionLevel::Best, + } + } +} + +#[async_trait] +impl CompressionPort for GzipCompressionService { + async fn compress_data(&self, data: &[u8], level: PortCompressionLevel) -> Result, DomainError> { + CompressionService::compress_data(self, data, level.into()).await.map_err(DomainError::from) + } + + async fn decompress_data(&self, compressed_data: &[u8]) -> Result, DomainError> { + CompressionService::decompress_data(self, compressed_data).await.map_err(DomainError::from) + } + + fn should_compress(&self, mime_type: &str, size: u64) -> bool { + CompressionService::should_compress(self, mime_type, size) + } +} + #[cfg(test)] mod tests { use super::*; @@ -359,13 +393,13 @@ mod tests { let data = "Hello, world! ".repeat(1000).into_bytes(); // Comprimir - let compressed = service.compress_data(&data, CompressionLevel::Default).await.unwrap(); + let compressed = CompressionService::compress_data(&service, &data, CompressionLevel::Default).await.unwrap(); // Verificar que la compresión reduce el tamaño assert!(compressed.len() < data.len()); // Descomprimir - let decompressed = service.decompress_data(&compressed).await.unwrap(); + let decompressed = CompressionService::decompress_data(&service, &compressed).await.unwrap(); // Verificar que los datos originales se recuperan correctamente assert_eq!(decompressed, data); @@ -396,7 +430,7 @@ mod tests { }).await.unwrap(); // Descomprimir los datos - let decompressed = service.decompress_data(&compressed_bytes).await.unwrap(); + let decompressed = CompressionService::decompress_data(&service, &compressed_bytes).await.unwrap(); // Verificar resultado let expected = "Hello, world! This is a test of streaming compression."; @@ -408,16 +442,16 @@ mod tests { let service = GzipCompressionService::new(); // Casos que no deberían comprimirse - assert!(!service.should_compress("image/jpeg", 100 * 1024)); - assert!(!service.should_compress("video/mp4", 10 * 1024 * 1024)); - assert!(!service.should_compress("application/zip", 5 * 1024 * 1024)); + assert!(!CompressionService::should_compress(&service, "image/jpeg", 100 * 1024)); + assert!(!CompressionService::should_compress(&service, "video/mp4", 10 * 1024 * 1024)); + assert!(!CompressionService::should_compress(&service, "application/zip", 5 * 1024 * 1024)); // Casos que sí deberían comprimirse - assert!(service.should_compress("text/html", 100 * 1024)); - assert!(service.should_compress("application/json", 200 * 1024)); - assert!(service.should_compress("text/plain", 1024 * 1024)); + assert!(CompressionService::should_compress(&service, "text/html", 100 * 1024)); + assert!(CompressionService::should_compress(&service, "application/json", 200 * 1024)); + assert!(CompressionService::should_compress(&service, "text/plain", 1024 * 1024)); // Archivos pequeños no deberían comprimirse independientemente del tipo - assert!(!service.should_compress("text/html", 10 * 1024)); + assert!(!CompressionService::should_compress(&service, "text/html", 10 * 1024)); } } \ No newline at end of file diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 20b4ce91..7267b916 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -5,7 +5,7 @@ //! to the same physical blob. //! //! Architecture: -//! ``` +//! ```text //! ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ //! │ User Files │────▶│ Dedup Index │────▶│ Blob Store │ //! │ (references) │ │ (hash→metadata) │ │ (actual data) │ @@ -26,6 +26,15 @@ use tokio::sync::RwLock; use sha2::{Sha256, Digest}; use bytes::Bytes; use serde::{Deserialize, Serialize}; +use async_trait::async_trait; + +use crate::application::ports::dedup_ports::{ + DedupPort, + BlobMetadataDto, + DedupResultDto, + DedupStatsDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; /// Chunk size for streaming hash calculation (256KB) const HASH_CHUNK_SIZE: usize = 256 * 1024; @@ -664,6 +673,120 @@ impl DedupService { } } +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert infra DedupResult to port DedupResultDto. +impl From for DedupResultDto { + fn from(result: DedupResult) -> Self { + match result { + DedupResult::NewBlob { hash, size, blob_path } => { + DedupResultDto::NewBlob { hash, size, blob_path } + } + DedupResult::ExistingBlob { hash, size, blob_path, saved_bytes } => { + DedupResultDto::ExistingBlob { hash, size, blob_path, saved_bytes } + } + } + } +} + +/// Convert infra BlobMetadata to port BlobMetadataDto. +impl From for BlobMetadataDto { + fn from(m: BlobMetadata) -> Self { + BlobMetadataDto { + hash: m.hash, + size: m.size, + ref_count: m.ref_count, + content_type: m.content_type, + } + } +} + +/// Convert infra DedupStats to port DedupStatsDto. +impl From for DedupStatsDto { + fn from(s: DedupStats) -> Self { + DedupStatsDto { + total_blobs: s.total_blobs, + total_bytes_stored: s.total_bytes_stored, + total_bytes_referenced: s.total_bytes_referenced, + bytes_saved: s.bytes_saved, + dedup_hits: s.dedup_hits, + dedup_ratio: s.dedup_ratio, + } + } +} + +#[async_trait] +impl DedupPort for DedupService { + async fn store_bytes( + &self, + content: &[u8], + content_type: Option, + ) -> Result { + self.store_bytes(content, content_type).await + .map(Into::into) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) + } + + async fn store_from_file( + &self, + source_path: &Path, + content_type: Option, + ) -> Result { + self.store_from_file(source_path, content_type).await + .map(Into::into) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) + } + + async fn blob_exists(&self, hash: &str) -> bool { + self.blob_exists(hash).await + } + + async fn get_blob_metadata(&self, hash: &str) -> Option { + self.get_blob_metadata(hash).await.map(Into::into) + } + + async fn read_blob(&self, hash: &str) -> Result, DomainError> { + self.read_blob(hash).await + .map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e)) + } + + async fn read_blob_bytes(&self, hash: &str) -> Result { + self.read_blob_bytes(hash).await + .map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e)) + } + + async fn add_reference(&self, hash: &str) -> Result<(), DomainError> { + self.add_reference(hash).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e)) + } + + async fn remove_reference(&self, hash: &str) -> Result { + self.remove_reference(hash).await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e)) + } + + fn hash_bytes(&self, content: &[u8]) -> String { + DedupService::hash_bytes(content) + } + + async fn hash_file(&self, path: &Path) -> Result { + DedupService::hash_file(path).await.map_err(DomainError::from) + } + + async fn get_stats(&self) -> DedupStatsDto { + self.get_stats().await.into() + } + + async fn flush(&self) -> Result<(), DomainError> { + self.flush().await.map_err(DomainError::from) + } + + async fn verify_integrity(&self) -> Result, DomainError> { + self.verify_integrity().await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -675,7 +798,8 @@ mod tests { let service = DedupService::new(temp_dir.path()); service.initialize().await.unwrap(); - let content = b"Hello, World! This is test content."; + // Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in + let content = &b"Hello, World! This is test content for dedup. ".repeat(100); // First store let result1 = service.store_bytes(content, None).await.unwrap(); @@ -698,7 +822,8 @@ mod tests { let service = DedupService::new(temp_dir.path()); service.initialize().await.unwrap(); - let content = b"Test content for reference counting"; + // Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in + let content = &b"Test content for reference counting. ".repeat(120); // Store twice let result1 = service.store_bytes(content, None).await.unwrap(); diff --git a/src/infrastructure/services/file_content_cache.rs b/src/infrastructure/services/file_content_cache.rs index aaa5aeda..f3fa5ed2 100644 --- a/src/infrastructure/services/file_content_cache.rs +++ b/src/infrastructure/services/file_content_cache.rs @@ -213,6 +213,34 @@ pub struct CacheStats { /// Thread-safe wrapper for sharing across handlers pub type SharedFileContentCache = Arc; +// ─── ContentCachePort implementation ───────────────────────── + +use async_trait::async_trait; +use crate::application::ports::cache_ports::ContentCachePort; + +#[async_trait] +impl ContentCachePort for FileContentCache { + fn should_cache(&self, size: usize) -> bool { + FileContentCache::should_cache(self, size) + } + + async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { + FileContentCache::get(self, file_id).await + } + + async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { + FileContentCache::put(self, file_id, content, etag, content_type).await + } + + async fn invalidate(&self, file_id: &str) { + FileContentCache::invalidate(self, file_id).await + } + + async fn clear(&self) { + FileContentCache::clear(self).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/infrastructure/services/file_metadata_cache.rs b/src/infrastructure/services/file_metadata_cache.rs index f7b36ae7..421cc495 100644 --- a/src/infrastructure/services/file_metadata_cache.rs +++ b/src/infrastructure/services/file_metadata_cache.rs @@ -598,6 +598,56 @@ impl FileMetadataCache { } } +// ─── MetadataCachePort implementation ──────────────────────── + +use async_trait::async_trait; +use crate::application::ports::cache_ports::{MetadataCachePort, CachedMetadataDto}; +use crate::common::errors::DomainError; + +#[async_trait] +impl MetadataCachePort for FileMetadataCache { + async fn get_metadata(&self, path: &Path) -> Option { + // Delegate to the existing rich get_metadata, then project into the DTO. + let fm = FileMetadataCache::get_metadata(self, path).await?; + Some(CachedMetadataDto { + path: fm.path, + exists: fm.exists, + is_file: fm.entry_type == CacheEntryType::File, + size: fm.size, + mime_type: fm.mime_type, + created_at: fm.created_at, + modified_at: fm.modified_at, + }) + } + + async fn is_file(&self, path: &Path) -> Option { + FileMetadataCache::is_file(self, path).await + } + + async fn refresh_metadata(&self, path: &Path) -> Result { + let fm = FileMetadataCache::refresh_metadata(self, path) + .await + .map_err(|e| DomainError::internal_error("MetadataCache", e.to_string()))?; + Ok(CachedMetadataDto { + path: fm.path, + exists: fm.exists, + is_file: fm.entry_type == CacheEntryType::File, + size: fm.size, + mime_type: fm.mime_type, + created_at: fm.created_at, + modified_at: fm.modified_at, + }) + } + + async fn invalidate(&self, path: &Path) { + FileMetadataCache::invalidate(self, path).await + } + + async fn invalidate_directory(&self, dir_path: &Path) { + FileMetadataCache::invalidate_directory(self, dir_path).await + } +} + #[cfg(test)] mod tests { use super::*; @@ -648,10 +698,12 @@ mod tests { async fn test_directory_operations() { // Crear estructura de directorios para pruebas let temp_dir = tempdir().unwrap(); - let sub_dir = temp_dir.path().join("subdir"); + // Canonicalize to handle macOS /var -> /private/var symlinks + let base_path = temp_dir.path().canonicalize().unwrap(); + let sub_dir = base_path.join("subdir"); fs::create_dir(&sub_dir).await.unwrap(); - let file1 = temp_dir.path().join("file1.txt"); + let file1 = base_path.join("file1.txt"); let file2 = sub_dir.join("file2.txt"); File::create(&file1).await.unwrap(); @@ -662,20 +714,19 @@ mod tests { let cache = FileMetadataCache::new(config, 1000); // Precargar directorio recursivamente - let count = cache.preload_directory(temp_dir.path(), true, 2).await.unwrap(); - assert_eq!(count, 3); // dir, subdir, 2 files + // preload_directory caches the *contents* of the directory, not the root itself + let count = cache.preload_directory(&base_path, true, 2).await.unwrap(); + assert_eq!(count, 3); // subdir, file1, file2 - // Verificar existencia en caché - assert_eq!(cache.is_dir(temp_dir.path()).await, Some(true)); + // Verificar existencia en caché (solo contenido, no la raíz) assert_eq!(cache.is_dir(&sub_dir).await, Some(true)); assert_eq!(cache.is_file(&file1).await, Some(true)); assert_eq!(cache.is_file(&file2).await, Some(true)); // Invalidar directorio y contenido - cache.invalidate_directory(temp_dir.path()).await; + cache.invalidate_directory(&base_path).await; // Verificar que nada existe en caché - assert!(cache.exists(temp_dir.path()).await.is_none()); assert!(cache.exists(&sub_dir).await.is_none()); assert!(cache.exists(&file1).await.is_none()); assert!(cache.exists(&file2).await.is_none()); diff --git a/src/infrastructure/services/id_mapping_service.rs b/src/infrastructure/services/id_mapping_service.rs index 199efafd..4ecdbaea 100644 --- a/src/infrastructure/services/id_mapping_service.rs +++ b/src/infrastructure/services/id_mapping_service.rs @@ -515,6 +515,7 @@ impl Clone for IdMappingService { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; use tempfile::tempdir; #[tokio::test] diff --git a/src/infrastructure/services/image_transcode_service.rs b/src/infrastructure/services/image_transcode_service.rs index 9880f952..cdfe6837 100644 --- a/src/infrastructure/services/image_transcode_service.rs +++ b/src/infrastructure/services/image_transcode_service.rs @@ -18,6 +18,14 @@ use bytes::Bytes; use lru::LruCache; use std::num::NonZeroUsize; use image::{ImageFormat, DynamicImage}; +use async_trait::async_trait; + +use crate::application::ports::transcode_ports::{ + ImageTranscodePort, + OutputFormat as PortOutputFormat, + TranscodeStatsDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; /// Maximum file size for transcoding (5MB - larger files stream directly) pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024; @@ -354,6 +362,59 @@ impl ImageTranscodeService { } } +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert port OutputFormat to infra OutputFormat. +impl From for OutputFormat { + fn from(fmt: PortOutputFormat) -> Self { + match fmt { + PortOutputFormat::WebP => OutputFormat::WebP, + } + } +} + +#[async_trait] +impl ImageTranscodePort for ImageTranscodeService { + fn can_transcode(&self, mime_type: &str) -> bool { + ImageTranscodeService::can_transcode(mime_type) + } + + fn should_transcode(&self, mime_type: &str, file_size: u64) -> bool { + ImageTranscodeService::should_transcode(mime_type, file_size) + } + + async fn get_transcoded( + &self, + file_id: &str, + original_content: &[u8], + original_mime: &str, + target_format: PortOutputFormat, + ) -> Result<(Bytes, String, bool), DomainError> { + self.get_transcoded(file_id, original_content, original_mime, target_format.into()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ImageTranscode", e)) + } + + async fn invalidate(&self, file_id: &str) { + self.invalidate(file_id).await + } + + async fn get_stats(&self) -> TranscodeStatsDto { + let stats = self.get_stats().await; + TranscodeStatsDto { + cache_hits: stats.cache_hits, + disk_hits: stats.disk_hits, + transcodes: stats.transcodes, + bytes_saved: stats.bytes_saved, + transcode_errors: stats.transcode_errors, + } + } + + async fn clear_cache(&self) -> Result<(), DomainError> { + self.clear_cache().await.map_err(DomainError::from) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index b59c261d..1e113e34 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -2,7 +2,6 @@ pub mod file_system_i18n_service; pub mod file_system_utils; pub mod id_mapping_service; pub mod id_mapping_optimizer; -pub mod cache_manager; pub mod file_metadata_cache; pub mod file_content_cache; pub mod compression_service; diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 0368958c..99d7befe 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -19,6 +19,14 @@ use image::{ImageFormat, imageops::FilterType}; use lru::LruCache; use std::num::NonZeroUsize; use bytes::Bytes; +use async_trait::async_trait; + +use crate::application::ports::thumbnail_ports::{ + ThumbnailPort, + ThumbnailSize as PortThumbnailSize, + ThumbnailStatsDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; /// Thumbnail sizes supported by the system #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -329,6 +337,60 @@ impl ThumbnailService { } } +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert port ThumbnailSize to infra ThumbnailSize. +impl From for ThumbnailSize { + fn from(size: PortThumbnailSize) -> Self { + match size { + PortThumbnailSize::Icon => ThumbnailSize::Icon, + PortThumbnailSize::Preview => ThumbnailSize::Preview, + PortThumbnailSize::Large => ThumbnailSize::Large, + } + } +} + +#[async_trait] +impl ThumbnailPort for ThumbnailService { + fn is_supported_image(&self, mime_type: &str) -> bool { + ThumbnailService::is_supported_image(mime_type) + } + + async fn get_thumbnail( + &self, + file_id: &str, + size: PortThumbnailSize, + original_path: &Path, + ) -> Result { + self.get_thumbnail(file_id, size.into(), original_path) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) + } + + fn generate_all_sizes_background( + self: Arc, + file_id: String, + original_path: PathBuf, + ) { + ThumbnailService::generate_all_sizes_background(self, file_id, original_path) + } + + async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError> { + self.delete_thumbnails(file_id) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) + } + + async fn get_stats(&self) -> ThumbnailStatsDto { + let stats = self.get_stats().await; + ThumbnailStatsDto { + cached_thumbnails: stats.cached_thumbnails, + cache_size_bytes: stats.cache_size_bytes, + max_cache_bytes: stats.max_cache_bytes, + } + } +} + /// Thumbnail service errors #[derive(Debug, thiserror::Error)] pub enum ThumbnailError { diff --git a/src/infrastructure/services/trash_cleanup_service.rs b/src/infrastructure/services/trash_cleanup_service.rs index 2ee496f6..3484b0a8 100644 --- a/src/infrastructure/services/trash_cleanup_service.rs +++ b/src/infrastructure/services/trash_cleanup_service.rs @@ -78,8 +78,8 @@ impl TrashCleanupService { // Eliminar cada elemento expirado for item in expired_items { - let trash_id = item.id.to_string(); - let user_id = item.user_id.to_string(); + let trash_id = item.id().to_string(); + let user_id = item.user_id().to_string(); debug!("Eliminando elemento expirado: id={}, user={}", trash_id, user_id); diff --git a/src/infrastructure/services/write_behind_cache.rs b/src/infrastructure/services/write_behind_cache.rs index 3335f303..99cac4df 100644 --- a/src/infrastructure/services/write_behind_cache.rs +++ b/src/infrastructure/services/write_behind_cache.rs @@ -19,6 +19,10 @@ use tokio::sync::{RwLock, mpsc}; use tokio::fs; use tokio::io::AsyncWriteExt; use bytes::Bytes; +use async_trait::async_trait; + +use crate::application::ports::cache_ports::{WriteBehindCachePort, WriteBehindStatsDto}; +use crate::domain::errors::DomainError; /// Maximum size for write-behind cache (files larger bypass cache) const WRITE_BEHIND_MAX_SIZE: usize = 1024 * 1024; // 1MB @@ -364,6 +368,56 @@ impl WriteBehindCache { } } +// ─── Port implementation ───────────────────────────────────────────────────── + +#[async_trait] +impl WriteBehindCachePort for WriteBehindCache { + fn is_eligible_size(&self, size: usize) -> bool { + WriteBehindCache::is_eligible(size) + } + + async fn put_pending( + &self, + file_id: String, + content: Bytes, + target_path: PathBuf, + ) -> Result { + self.put_pending(file_id, content, target_path).await.map_err(DomainError::from) + } + + async fn get_pending(&self, file_id: &str) -> Option { + self.get_pending(file_id).await + } + + async fn is_pending(&self, file_id: &str) -> bool { + self.is_pending(file_id).await + } + + async fn force_flush(&self, file_id: &str) -> Result<(), DomainError> { + self.force_flush(file_id).await.map_err(DomainError::from) + } + + async fn flush_all(&self) -> Result<(), DomainError> { + self.flush_all().await.map_err(DomainError::from) + } + + async fn shutdown(&self) -> Result<(), DomainError> { + self.shutdown().await.map_err(DomainError::from) + } + + async fn get_stats(&self) -> WriteBehindStatsDto { + let stats = self.get_stats().await; + WriteBehindStatsDto { + pending_count: stats.pending_count, + pending_bytes: stats.pending_bytes, + total_writes: stats.total_writes, + total_bytes_written: stats.total_bytes_written, + cache_hits: stats.cache_hits, + avg_flush_time_us: stats.avg_flush_time_us, + } + } +} + impl Default for WriteBehindCache { fn default() -> Self { // Note: This creates a non-Arc version, prefer using new() diff --git a/src/infrastructure/services/zip_service.rs b/src/infrastructure/services/zip_service.rs index 1d43df16..09c600bf 100644 --- a/src/infrastructure/services/zip_service.rs +++ b/src/infrastructure/services/zip_service.rs @@ -2,10 +2,13 @@ use std::io::{Cursor, Read, Write}; use zip::{ZipWriter, write::SimpleFileOptions}; use thiserror::Error; use tracing::*; +use async_trait::async_trait; use crate::{ application::dtos::file_dto::FileDto, application::dtos::folder_dto::FolderDto, - application::ports::inbound::{FileUseCase, FolderUseCase}, + application::ports::inbound::FolderUseCase, + application::ports::file_ports::FileRetrievalUseCase, + application::ports::zip_ports::ZipPort, common::errors::{Result, DomainError, ErrorKind}, }; use std::sync::Arc; @@ -45,13 +48,13 @@ impl From for DomainError { /// Servicio para crear archivos ZIP pub struct ZipService { - file_service: Arc, + file_service: Arc, folder_service: Arc, } impl ZipService { /// Crea una nueva instancia del servicio ZIP con una referencia al servicio de archivos - pub fn new(file_service: Arc, folder_service: Arc) -> Self { + pub fn new(file_service: Arc, folder_service: Arc) -> Self { Self { file_service, folder_service, @@ -225,4 +228,17 @@ impl ZipService { } } } +} + +// ─── Port implementation ───────────────────────────────────────────────────── + +#[async_trait] +impl ZipPort for ZipService { + async fn create_folder_zip( + &self, + folder_id: &str, + folder_name: &str, + ) -> std::result::Result, DomainError> { + self.create_folder_zip(folder_id, folder_name).await + } } \ No newline at end of file diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 4527eda5..b4b1694a 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -9,7 +9,7 @@ use axum::{ use crate::common::di::AppState; use crate::application::dtos::user_dto::{ - LoginDto, RegisterDto, UserDto, ChangePasswordDto, RefreshTokenDto, AuthResponseDto + LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto }; use crate::interfaces::errors::AppError; @@ -51,29 +51,6 @@ async fn register( } }; - // Create a temporary mock response for testing - // This is a fallback solution to bypass database issues - if cfg!(debug_assertions) && dto.username == "test" { - tracing::info!("Using test registration, bypassing database"); - - // Create a mock user response - let now = chrono::Utc::now(); - let mock_user = UserDto { - id: "test-user-id".to_string(), - username: dto.username.clone(), - email: dto.email.clone(), - role: "user".to_string(), - active: true, - storage_quota_bytes: 1024 * 1024 * 1024, // 1GB - storage_used_bytes: 0, - created_at: now, - updated_at: now, - last_login_at: None, - }; - - return Ok((StatusCode::CREATED, Json(mock_user))); - } - // Check if this is a fresh install tracing::info!("New user registration detected, checking if it's a fresh install"); @@ -153,8 +130,6 @@ async fn login( // Add detailed logging for debugging tracing::info!("Login attempt for user: {}", dto.username); - // Normal login process - // Verify auth service exists let auth_service = match state.auth_service.as_ref() { Some(service) => { @@ -167,35 +142,6 @@ async fn login( } }; - // Create a temporary mock response for testing - // This is a fallback solution to bypass database issues - if cfg!(debug_assertions) && dto.username == "test" && dto.password == "test" { - tracing::info!("Using test credentials, bypassing database"); - - // Create a mock response - let now = chrono::Utc::now(); - let mock_response = AuthResponseDto { - user: UserDto { - id: "test-user-id".to_string(), - username: dto.username.clone(), - email: format!("{}@example.com", dto.username), - role: "user".to_string(), - active: true, - storage_quota_bytes: 1024 * 1024 * 1024, // 1GB - storage_used_bytes: 0, - created_at: now, - updated_at: now, - last_login_at: None, - }, - access_token: "mock_access_token".to_string(), - refresh_token: "mock_refresh_token".to_string(), - token_type: "Bearer".to_string(), - expires_in: 3600, - }; - - return Ok((StatusCode::OK, Json(mock_response))); - } - // Try the normal login process match auth_service.auth_application_service.login(dto.clone()).await { Ok(auth_response) => { @@ -226,38 +172,7 @@ async fn refresh_token( // Check if this refresh token is being used too frequently // Log the refresh attempt for debugging - tracing::info!("Token refresh requested with refresh token: {}", - dto.refresh_token.chars().take(8).collect::() + "..."); - - // Handle test/mock tokens with simplified response - if dto.refresh_token.contains("mock") || dto.refresh_token == "mock_refresh_token" { - tracing::info!("Mock refresh token detected, returning simplified response"); - - // Create a mock response that will work with our frontend - let now = chrono::Utc::now(); - let mock_user = UserDto { - id: "test-user-id".to_string(), - username: "test".to_string(), - email: "test@example.com".to_string(), - role: "user".to_string(), - active: true, - storage_quota_bytes: 1024 * 1024 * 1024, // 1GB - storage_used_bytes: 0, - created_at: now, - updated_at: now, - last_login_at: None, - }; - - let auth_response = AuthResponseDto { - user: mock_user, - access_token: "mock_access_token_new".to_string(), - refresh_token: "mock_refresh_token_new".to_string(), - token_type: "Bearer".to_string(), - expires_in: 86400 * 30, // 30 days - }; - - return Ok((StatusCode::OK, Json(auth_response))); - } + tracing::info!("Token refresh requested"); // Normal process for real tokens let auth_service = state.auth_service.as_ref() diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 486412ca..7b3ed141 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use serde_json::json; use crate::common::di::AppState; +use crate::interfaces::middleware::auth::AuthUser; use crate::application::dtos::address_book_dto::{ AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto @@ -83,8 +84,9 @@ pub fn carddav_routes() -> Router { // Address Book handlers async fn list_address_books( State(state): State, + auth_user: AuthUser, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -142,9 +144,10 @@ async fn create_address_book( async fn get_address_book( State(state): State, + auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -176,10 +179,11 @@ async fn get_address_book( async fn update_address_book( State(state): State, + auth_user: AuthUser, Path(id): Path, Json(mut update): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; update.user_id = user_id.to_string(); match &state.contact_service { @@ -214,9 +218,10 @@ async fn update_address_book( async fn delete_address_book( State(state): State, + auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -246,9 +251,10 @@ async fn delete_address_book( async fn get_address_book_shares( State(state): State, + auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -278,10 +284,11 @@ async fn get_address_book_shares( async fn share_address_book( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, Json(mut dto): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; dto.address_book_id = address_book_id; match &state.contact_service { @@ -314,9 +321,10 @@ async fn share_address_book( async fn unshare_address_book( State(state): State, + auth_user: AuthUser, Path((address_book_id, shared_with)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -354,9 +362,10 @@ async fn unshare_address_book( // Contact handlers async fn list_contacts( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -388,10 +397,11 @@ async fn list_contacts( async fn search_contacts( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, axum::extract::Query(params): axum::extract::Query>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; let query = params.get("q").unwrap_or(&String::new()).to_string(); match &state.contact_service { @@ -425,10 +435,11 @@ async fn search_contacts( async fn create_contact( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, Json(mut dto): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; dto.address_book_id = address_book_id; dto.user_id = user_id.to_string(); @@ -457,10 +468,11 @@ async fn create_contact( async fn create_contact_from_vcard( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, Json(mut dto): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; dto.address_book_id = address_book_id; dto.user_id = user_id.to_string(); @@ -489,9 +501,10 @@ async fn create_contact_from_vcard( async fn get_contact( State(state): State, + auth_user: AuthUser, Path((_, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -523,10 +536,11 @@ async fn get_contact( async fn update_contact( State(state): State, + auth_user: AuthUser, Path((_, contact_id)): Path<(String, String)>, Json(mut update): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; update.user_id = user_id.to_string(); match &state.contact_service { @@ -561,9 +575,10 @@ async fn update_contact( async fn delete_contact( State(state): State, + auth_user: AuthUser, Path((_, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -593,9 +608,10 @@ async fn delete_contact( async fn get_contact_vcard( State(state): State, + auth_user: AuthUser, Path((_, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -639,9 +655,10 @@ async fn get_contact_vcard( // Group handlers async fn list_groups( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -673,10 +690,11 @@ async fn list_groups( async fn create_group( State(state): State, + auth_user: AuthUser, Path(address_book_id): Path, Json(mut dto): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; dto.address_book_id = address_book_id; dto.user_id = user_id.to_string(); @@ -705,9 +723,10 @@ async fn create_group( async fn get_group( State(state): State, + auth_user: AuthUser, Path((_, group_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -739,10 +758,11 @@ async fn get_group( async fn update_group( State(state): State, + auth_user: AuthUser, Path((_, group_id)): Path<(String, String)>, Json(mut update): Json, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; update.user_id = user_id.to_string(); match &state.contact_service { @@ -777,9 +797,10 @@ async fn update_group( async fn delete_group( State(state): State, + auth_user: AuthUser, Path((_, group_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -809,9 +830,10 @@ async fn delete_group( async fn list_contacts_in_group( State(state): State, + auth_user: AuthUser, Path((_, group_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -843,9 +865,10 @@ async fn list_contacts_in_group( async fn add_contact_to_group( State(state): State, + auth_user: AuthUser, Path((group_id, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -882,9 +905,10 @@ async fn add_contact_to_group( async fn remove_contact_from_group( State(state): State, + auth_user: AuthUser, Path((group_id, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { @@ -921,9 +945,10 @@ async fn remove_contact_from_group( async fn list_groups_for_contact( State(state): State, + auth_user: AuthUser, Path(contact_id): Path, ) -> impl IntoResponse { - let user_id = "default_user"; // In production, get this from auth middleware + let user_id = &auth_user.id; match &state.contact_service { Some(contact_service) => { diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 9f66539c..850d44df 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -18,7 +18,8 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::common::di::AppState; -use crate::infrastructure::services::chunked_upload_service::DEFAULT_CHUNK_SIZE; +use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; +use crate::domain::errors::ErrorKind; /// Request body for creating an upload session #[derive(Debug, Deserialize)] @@ -115,7 +116,7 @@ impl ChunkedUploadHandler { Err(e) => { tracing::error!("Failed to create upload session: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": e + "error": e.to_string() }))).into_response() } } @@ -166,18 +167,15 @@ impl ChunkedUploadHandler { .into_response() } Err(e) => { - let status = if e.contains("not found") { - StatusCode::NOT_FOUND - } else if e.contains("Invalid") || e.contains("already uploaded") { - StatusCode::BAD_REQUEST - } else if e.contains("Checksum") { - StatusCode::CONFLICT - } else { - StatusCode::INTERNAL_SERVER_ERROR + let status = match e.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, + ErrorKind::AlreadyExists => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(serde_json::json!({ - "error": e + "error": e.to_string() }))).into_response() } } @@ -208,7 +206,7 @@ impl ChunkedUploadHandler { } Err(e) => { (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": e + "error": e.to_string() }))).into_response() } } @@ -222,23 +220,21 @@ impl ChunkedUploadHandler { Path(upload_id): Path, ) -> impl IntoResponse { let chunked_service = &state.core.chunked_upload_service; - let file_service = &state.applications.file_service_concrete; + let upload_service = &state.applications.file_upload_service; // Assemble chunks let (assembled_path, filename, folder_id, content_type, total_size) = match chunked_service.complete_upload(&upload_id).await { Ok(result) => result, Err(e) => { - let status = if e.contains("not found") { - StatusCode::NOT_FOUND - } else if e.contains("not complete") { - StatusCode::CONFLICT - } else { - StatusCode::INTERNAL_SERVER_ERROR + let status = match e.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::InvalidInput | ErrorKind::AlreadyExists => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, }; return (status, Json(serde_json::json!({ - "error": e + "error": e.to_string() }))).into_response(); } }; @@ -255,7 +251,7 @@ impl ChunkedUploadHandler { }; // Upload via normal service (this handles path resolution, metadata, etc.) - match file_service.upload_file_from_bytes( + match upload_service.upload_file( filename.clone(), folder_id.clone(), content_type, @@ -299,7 +295,7 @@ impl ChunkedUploadHandler { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(e) => { (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": e + "error": e.to_string() }))).into_response() } } diff --git a/src/interfaces/api/handlers/dedup_handler.rs b/src/interfaces/api/handlers/dedup_handler.rs index 035a57ce..b4f32060 100644 --- a/src/interfaces/api/handlers/dedup_handler.rs +++ b/src/interfaces/api/handlers/dedup_handler.rs @@ -8,7 +8,7 @@ use bytes::Bytes; use serde::Serialize; use crate::common::di::AppState; -use crate::infrastructure::services::dedup_service::DedupResult; +use crate::application::ports::dedup_ports::DedupResultDto; /// Global application state for dependency injection type GlobalState = AppState; @@ -183,8 +183,8 @@ impl DedupHandler { match dedup.store_bytes(&data, Some(content_type)).await { Ok(result) => { let (is_new, bytes_saved) = match &result { - DedupResult::NewBlob { .. } => (true, 0), - DedupResult::ExistingBlob { saved_bytes, .. } => (false, *saved_bytes), + DedupResultDto::NewBlob { .. } => (true, 0), + DedupResultDto::ExistingBlob { saved_bytes, .. } => (false, *saved_bytes), }; let metadata = dedup.get_blob_metadata(result.hash()).await; diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index f0ebec11..fb059b28 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use axum::{ extract::{Path, State, Multipart, Query}, http::{StatusCode, header, HeaderMap, Response}, @@ -11,517 +10,182 @@ use serde::Deserialize; use std::collections::HashMap; use http_range_header::parse_range_header; -use crate::application::services::file_service::{FileService, FileServiceError}; -use crate::infrastructure::services::compression_service::{ - CompressionService, GzipCompressionService, CompressionLevel -}; +use crate::application::ports::compression_ports::{CompressionPort, CompressionLevel}; +use crate::application::ports::file_ports::OptimizedFileContent; use crate::common::di::AppState; use crate::interfaces::middleware::auth::CurrentUserId; /** * Type aliases for dependency injection state. - * These aliases improve code readability when working with service dependencies. */ -/// State containing the file service for dependency injection -type FileServiceState = Arc; /// Global application state for dependency injection type GlobalState = AppState; /** * API handler for file-related operations. * - * The FileHandler is responsible for processing HTTP requests related to file operations. - * It handles: - * - * 1. File uploads through multipart form data - * 2. File downloads with optional compression - * 3. Listing files in folders - * 4. Moving files between folders - * 5. Deleting files (with trash integration) - * - * This component acts as an adapter in the hexagonal architecture, translating - * between HTTP requests/responses and application service calls. It handles - * HTTP-specific concerns like status codes, headers, and request parsing while - * delegating business logic to the application services. + * Acts as a thin HTTP adapter in the hexagonal architecture: it parses requests, + * delegates business logic to application services, and maps results to HTTP + * responses. No infrastructure or strategy logic lives here. */ pub struct FileHandler; -/// Threshold for using streaming upload (files >= 1MB use streaming) -const STREAMING_UPLOAD_THRESHOLD: usize = 1 * 1024 * 1024; - -/// Threshold for write-behind cache (files < 256KB get instant response) -const WRITE_BEHIND_THRESHOLD: usize = 256 * 1024; - impl FileHandler { + // ═══════════════════════════════════════════════════════════════════════ + // UPLOAD + // ═══════════════════════════════════════════════════════════════════════ + /// Uploads a file with TRUE STREAMING support and Write-Behind Cache /// - /// Three-tier upload strategy: - /// - /// 1. INSTANT (<256KB): Write-behind cache - /// - Store in RAM, respond immediately - /// - Flush to disk asynchronously - /// - User perceives ~0ms latency - /// - /// 2. BUFFERED (256KB - 1MB): In-memory processing - /// - Fast for medium files - /// - Direct write to disk before response - /// - /// 3. STREAMING (≥1MB): Direct disk writes - /// - Constant memory regardless of file size - /// - Uses atomic rename for crash safety + /// The three-tier strategy (write-behind / buffered / streaming) and dedup + /// are fully handled by `FileUploadUseCase::smart_upload`. + /// This handler only extracts multipart fields and maps the result to HTTP. pub async fn upload_file( - State(service): State, + State(state): State, mut multipart: Multipart, ) -> impl IntoResponse { - use futures::stream; - use std::pin::Pin; - let mut folder_id: Option = None; - + tracing::debug!("📤 Processing file upload request"); - + while let Some(field) = multipart.next_field().await.unwrap_or(None) { let name = field.name().unwrap_or("").to_string(); - + if name == "folder_id" { - let folder_id_value = field.text().await.unwrap_or_default(); - if !folder_id_value.is_empty() { - folder_id = Some(folder_id_value); - } + let v = field.text().await.unwrap_or_default(); + if !v.is_empty() { folder_id = Some(v); } continue; } - + if name == "file" { let filename = field.file_name().unwrap_or("unnamed").to_string(); let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); - - tracing::info!("📤 UPLOAD START: {} (folder: {:?})", filename, folder_id); - - // Collect all chunks from the field - we need to consume the field completely - // to avoid borrow issues with multipart + + // Collect chunks from multipart let mut chunks: Vec = Vec::new(); let mut total_size: usize = 0; let mut field = field; - while let Ok(Some(chunk)) = field.chunk().await { total_size += chunk.len(); chunks.push(chunk); - - // Log progress every 10MB - if total_size > 0 && total_size % (10 * 1024 * 1024) < chunks.last().map(|c| c.len()).unwrap_or(0) { - tracing::debug!( - "📥 Upload receiving: {} - {}MB", - filename, - total_size / (1024 * 1024) - ); - } } - - // Empty file check + + // Empty file if chunks.is_empty() { - return Self::upload_empty_file(service, filename, folder_id, content_type).await; - } - - // Decide upload strategy based on total size - if total_size >= STREAMING_UPLOAD_THRESHOLD { - // ═══════════════════════════════════════════════════════════════ - // STREAMING UPLOAD - For large files - // Create a stream from collected chunks and write to disk - // ═══════════════════════════════════════════════════════════════ - tracing::info!( - "📡 STREAMING UPLOAD: {} ({} MB, {} chunks)", - filename, - total_size / (1024 * 1024), - chunks.len() - ); - - // Convert chunks to a stream - let chunk_stream = stream::iter( - chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c)) - ); - let pinned_stream: Pin> + Send>> = - Box::pin(chunk_stream); - - // Use streaming upload - writes directly to disk - match service.upload_file_from_stream( - filename.clone(), - folder_id.clone(), - content_type.clone(), - pinned_stream, - ).await { - Ok(file) => { - tracing::info!( - "✅ STREAMING UPLOAD COMPLETE: {} ({} MB, ID: {})", - filename, - total_size / (1024 * 1024), - file.id - ); - return Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(axum::body::Body::from(serde_json::to_string(&file).unwrap())) - .unwrap() - .into_response(); - }, - Err(err) => { - tracing::error!("❌ STREAMING UPLOAD FAILED: {} - {}", filename, err); - return Self::error_response(err); - } - } - } else { - // ═══════════════════════════════════════════════════════════════ - // BUFFERED UPLOAD - For small files (<1MB) - // Faster for small files as we avoid temp file overhead - // ═══════════════════════════════════════════════════════════════ - tracing::debug!("💨 BUFFERED UPLOAD: {} ({} bytes)", filename, total_size); - - // Combine chunks efficiently - let data = if chunks.len() == 1 { - chunks.into_iter().next().unwrap().to_vec() - } else { - let mut combined = Vec::with_capacity(total_size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - combined + let upload_service = &state.applications.file_upload_service; + return match upload_service.upload_file(filename, folder_id, content_type, vec![]).await { + Ok(file) => Self::created_json_response(&file).into_response(), + Err(err) => Self::domain_error_response(err).into_response(), }; - - match service.upload_file_from_bytes( - filename.clone(), - folder_id.clone(), - content_type.clone(), - data, - ).await { - Ok(file) => { - tracing::info!("✅ BUFFERED UPLOAD COMPLETE: {} (ID: {})", filename, file.id); - return Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(axum::body::Body::from(serde_json::to_string(&file).unwrap())) - .unwrap() - .into_response(); - }, - Err(err) => { - tracing::error!("❌ BUFFERED UPLOAD FAILED: {} - {}", filename, err); - return Self::error_response(err); - } + } + + // Delegate to FileService (simple path, no write-behind/dedup) + let upload_service = &state.applications.file_upload_service; + let data = Self::combine_chunks(chunks, total_size); + match upload_service.upload_file(filename.clone(), folder_id, content_type, data).await { + Ok(file) => { + tracing::info!("✅ UPLOAD COMPLETE: {} (ID: {})", filename, file.id); + return Self::created_json_response(&file); + } + Err(err) => { + tracing::error!("❌ UPLOAD FAILED: {} - {}", filename, err); + return Self::domain_error_response(err); } } } } - - tracing::warn!("Upload request missing file field"); + (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file provided" }))).into_response() } - - /// Handle empty file uploads - async fn upload_empty_file( - service: Arc, - filename: String, - folder_id: Option, - content_type: String, - ) -> Response { - tracing::debug!("Uploading empty file: {}", filename); - match service.upload_file_from_bytes(filename.clone(), folder_id, content_type, vec![]).await { - Ok(file) => { - Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .body(axum::body::Body::from(serde_json::to_string(&file).unwrap())) - .unwrap() - }, - Err(err) => Self::error_response(err), - } - } - - /// Uploads a file with Write-Behind Cache for instant response - /// - /// This version uses AppState to access the write-behind cache, - /// enabling ~0ms perceived latency for small files (<256KB). - /// - /// Flow for small files: - /// 1. Generate file ID and metadata - /// 2. Store content in write-behind cache (+ dedup blob store) - /// 3. Respond immediately with 201 Created - /// 4. Background worker flushes to disk - /// - /// Deduplication: - /// - All uploads are stored in the dedup blob store - /// - Duplicate content is detected by SHA-256 hash - /// - Only one copy of identical content is stored + + /// Uploads a file with Write-Behind Cache + Dedup (smart strategy). + /// + /// Delegates entirely to `FileUploadUseCase::smart_upload` which picks the + /// optimal tier and handles deduplication internally. pub async fn upload_file_with_cache( State(state): State, mut multipart: Multipart, ) -> impl IntoResponse { - use futures::stream; - use std::pin::Pin; - use crate::infrastructure::services::write_behind_cache::WriteBehindCache; - - let service = &state.applications.file_service_concrete; - let write_behind = &state.core.write_behind_cache; - let dedup_service = &state.core.dedup_service; - + let upload_service = &state.applications.file_upload_service; let mut folder_id: Option = None; - - tracing::debug!("📤 Processing file upload request (with write-behind cache + dedup)"); - + + tracing::debug!("📤 Processing file upload request (with smart upload)"); + while let Some(field) = multipart.next_field().await.unwrap_or(None) { let name = field.name().unwrap_or("").to_string(); - + if name == "folder_id" { - let folder_id_value = field.text().await.unwrap_or_default(); - if !folder_id_value.is_empty() { - folder_id = Some(folder_id_value); - } + let v = field.text().await.unwrap_or_default(); + if !v.is_empty() { folder_id = Some(v); } continue; } - + if name == "file" { let filename = field.file_name().unwrap_or("unnamed").to_string(); let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); - + // Collect chunks let mut chunks: Vec = Vec::new(); let mut total_size: usize = 0; let mut field = field; - while let Ok(Some(chunk)) = field.chunk().await { total_size += chunk.len(); chunks.push(chunk); } - - // Empty file - handle separately + + // Empty file if chunks.is_empty() { - return Self::upload_empty_file(service.clone(), filename, folder_id, content_type).await.into_response(); - } - - // ═══════════════════════════════════════════════════════════════ - // DEDUPLICATION: Store content in blob store for dedup tracking - // This runs in parallel with normal upload to track duplicates - // ═══════════════════════════════════════════════════════════════ - let dedup_data: Vec = { - let mut combined = Vec::with_capacity(total_size); - for chunk in &chunks { - combined.extend_from_slice(chunk); - } - combined - }; - - // Store in dedup blob store (async, non-blocking for response) - let dedup_result = dedup_service.store_bytes(&dedup_data, Some(content_type.clone())).await; - match &dedup_result { - Ok(result) => { - if result.was_deduplicated() { - tracing::info!( - "🔗 DEDUP: {} - content already exists (hash: {}, saved {} bytes)", - filename, - &result.hash()[..12], - result.size() - ); - } else { - tracing::info!( - "💾 DEDUP: {} - new content stored (hash: {})", - filename, - &result.hash()[..12] - ); - } - }, - Err(e) => { - // Dedup failure is not fatal - continue with normal upload - tracing::warn!("⚠️ DEDUP: Failed to store in blob store: {}", e); - } - } - - tracing::info!( - "📤 UPLOAD: {} ({} bytes, folder: {:?}, strategy: {})", - filename, - total_size, - folder_id, - if total_size < WRITE_BEHIND_THRESHOLD { "WRITE-BEHIND" } - else if total_size < STREAMING_UPLOAD_THRESHOLD { "BUFFERED" } - else { "STREAMING" } - ); - - // ═══════════════════════════════════════════════════════════════ - // TIER 1: WRITE-BEHIND PURO (<256KB) - Zero latency upload - // 1. Register metadata (~0.1ms) - // 2. Cache content in RAM - // 3. Respond 201 IMMEDIATELY - // 4. Background: flush to disk - // ═══════════════════════════════════════════════════════════════ - if total_size < WRITE_BEHIND_THRESHOLD && WriteBehindCache::is_eligible(total_size) { - // Combine chunks - let data: Bytes = if chunks.len() == 1 { - chunks.into_iter().next().unwrap() - } else { - let mut combined = Vec::with_capacity(total_size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - combined.into() + let upload_svc = &state.applications.file_upload_service; + return match upload_svc.upload_file(filename, folder_id, content_type, vec![]).await { + Ok(file) => Self::created_json_response(&file).into_response(), + Err(err) => Self::domain_error_response(err).into_response(), }; - - // Register file metadata WITHOUT writing to disk - match service.register_file_deferred( - filename.clone(), - folder_id.clone(), - content_type.clone(), - total_size as u64, - ).await { - Ok((file, target_path)) => { - // Put content in write-behind cache for: - // 1. Immediate reads (before flush completes) - // 2. Background flush to disk - if let Err(e) = write_behind.put_pending( - file.id.clone(), - data, - target_path, - ).await { - tracing::error!("❌ Write-behind cache failed: {} - {}", file.id, e); - // Fallback: return error (file exists in metadata but not on disk) - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Failed to queue file write: {}", e) - }))).into_response(); - } - - tracing::info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", filename, file.id); - return Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(axum::body::Body::from(serde_json::to_string(&file).unwrap())) - .unwrap() - .into_response(); - }, - Err(err) => { - tracing::error!("❌ WRITE-BEHIND REGISTRATION FAILED: {} - {}", filename, err); - return Self::error_response(err).into_response(); - } - } } - - // ═══════════════════════════════════════════════════════════════ - // TIER 2: STREAMING UPLOAD (≥1MB) - Direct disk writes - // ═══════════════════════════════════════════════════════════════ - if total_size >= STREAMING_UPLOAD_THRESHOLD { - let chunk_stream = stream::iter( - chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c)) - ); - let pinned_stream: Pin> + Send>> = - Box::pin(chunk_stream); - - match service.upload_file_from_stream( - filename.clone(), - folder_id.clone(), - content_type.clone(), - pinned_stream, - ).await { - Ok(file) => { - tracing::info!( - "✅ STREAMING UPLOAD COMPLETE: {} ({} MB, ID: {})", - filename, - total_size / (1024 * 1024), - file.id - ); - return Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(axum::body::Body::from(serde_json::to_string(&file).unwrap())) - .unwrap() - .into_response(); - }, - Err(err) => { - tracing::error!("❌ STREAMING UPLOAD FAILED: {} - {}", filename, err); - return Self::error_response(err).into_response(); - } + + // Delegate to smart_upload (handles write-behind, dedup, streaming) + match upload_service + .smart_upload(filename.clone(), folder_id, content_type, chunks, total_size) + .await + { + Ok((file, strategy)) => { + tracing::info!( + "✅ SMART UPLOAD: {} ({} bytes, strategy: {:?}, ID: {})", + filename, total_size, strategy, file.id + ); + return Self::created_json_response(&file).into_response(); } - } - - // ═══════════════════════════════════════════════════════════════ - // TIER 3: BUFFERED UPLOAD (256KB - 1MB) - // ═══════════════════════════════════════════════════════════════ - let data = if chunks.len() == 1 { - chunks.into_iter().next().unwrap().to_vec() - } else { - let mut combined = Vec::with_capacity(total_size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - combined - }; - - match service.upload_file_from_bytes( - filename.clone(), - folder_id.clone(), - content_type.clone(), - data, - ).await { - Ok(file) => { - tracing::info!("✅ BUFFERED UPLOAD COMPLETE: {} (ID: {})", filename, file.id); - return Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(axum::body::Body::from(serde_json::to_string(&file).unwrap())) - .unwrap() - .into_response(); - }, Err(err) => { - tracing::error!("❌ BUFFERED UPLOAD FAILED: {} - {}", filename, err); - return Self::error_response(err).into_response(); + tracing::error!("❌ SMART UPLOAD FAILED: {} - {}", filename, err); + return Self::domain_error_response(err).into_response(); } } } } - + (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file provided" }))).into_response() } - /// Build error response for upload failures - fn error_response(err: FileServiceError) -> Response { - let status = match &err { - FileServiceError::NotFound(_) => StatusCode::NOT_FOUND, - FileServiceError::AccessError(_) => StatusCode::SERVICE_UNAVAILABLE, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - - Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "application/json") - .body(axum::body::Body::from(serde_json::json!({ - "error": format!("Error uploading file: {}", err) - }).to_string())) - .unwrap() - } - - /// Get a thumbnail for an image file - /// - /// Supports three sizes: - /// - icon: 150x150 (for file listings) - /// - preview: 400x400 (for gallery view) - /// - large: 800x800 (for detail view) - /// - /// Thumbnails are: - /// - Generated on-demand if not cached - /// - Stored as WebP for smaller size - /// - Cached in memory for fast repeated access + // ═══════════════════════════════════════════════════════════════════════ + // THUMBNAILS + // ═══════════════════════════════════════════════════════════════════════ + + /// Get a thumbnail for an image file. + /// + /// Thumbnail orchestration (path resolution, generation, caching) stays here + /// because it is tightly coupled to HTTP response headers. pub async fn get_thumbnail( State(state): State, Path((id, size)): Path<(String, String)>, ) -> impl IntoResponse { - use crate::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize}; - - let service = &state.applications.file_service_concrete; + use crate::application::ports::thumbnail_ports::ThumbnailSize; + + let file_retrieval_service = &state.applications.file_retrieval_service; let thumbnail_service = &state.core.thumbnail_service; - - // Parse size parameter + let thumb_size = match size.as_str() { "icon" => ThumbnailSize::Icon, "preview" => ThumbnailSize::Preview, @@ -532,9 +196,8 @@ impl FileHandler { }))).into_response(); } }; - - // Get file info - let file = match service.get_file(&id).await { + + let file = match file_retrieval_service.get_file(&id).await { Ok(f) => f, Err(err) => { return (StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -542,24 +205,19 @@ impl FileHandler { }))).into_response(); } }; - - // Check if file is an image - if !ThumbnailService::is_supported_image(&file.mime_type) { + + if !thumbnail_service.is_supported_image(&file.mime_type) { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "File is not a supported image type" }))).into_response(); } - - // Build the file path from storage root + relative path + let storage_root = state.core.path_service.get_root_path(); let file_path = storage_root.join(&file.path); - - // Get or generate thumbnail + match thumbnail_service.get_thumbnail(&id, thumb_size, &file_path).await { Ok(data) => { - // Generate ETag for caching let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); - Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/webp") @@ -569,7 +227,7 @@ impl FileHandler { .body(Body::from(data)) .unwrap() .into_response() - }, + } Err(err) => { tracing::error!("Thumbnail generation failed: {}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ @@ -578,49 +236,46 @@ impl FileHandler { } } } - - /// Downloads a file with optimized 3-tier caching strategy - /// - /// Architecture for maximum performance: - /// 1. HOT CACHE (RAM): LRU cache for files <10MB - latency ~0.1ms - /// 2. STREAMING: Direct file streaming for files ≥10MB - no RAM overhead - /// - /// Also supports: - /// - ETag-based caching with 304 Not Modified responses - /// - Optional compression (disabled for streaming to preserve speed) - /// - Automatic WebP transcoding for images (30-50% smaller) + + // ═══════════════════════════════════════════════════════════════════════ + // DOWNLOAD + // ═══════════════════════════════════════════════════════════════════════ + + /// Downloads a file with optimized multi-tier strategy. + /// + /// The tier selection (write-behind → hot cache → WebP transcode → mmap → + /// streaming) is fully handled by `FileRetrievalUseCase::get_file_optimized`. + /// This handler only deals with HTTP concerns: ETag, Range, Content-Disposition, + /// and optional compression. pub async fn download_file( State(state): State, Path(id): Path, Query(params): Query>, headers: HeaderMap, ) -> impl IntoResponse { - let service = &state.applications.file_service_concrete; - let content_cache = &state.core.file_content_cache; - - // Get file info first to check it exists and get metadata - let file = match service.get_file(&id).await { + let retrieval = &state.applications.file_retrieval_service; + + // ── Get file metadata ──────────────────────────────────────── + let file_dto = match retrieval.get_file(&id).await { Ok(f) => f, Err(err) => { - let status = match &err { - FileServiceError::NotFound(_) => StatusCode::NOT_FOUND, - FileServiceError::AccessError(_) => StatusCode::SERVICE_UNAVAILABLE, - _ => StatusCode::INTERNAL_SERVER_ERROR, + let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR }; return (status, Json(serde_json::json!({ "error": err.to_string() }))).into_response(); } }; - - // Generate ETag based on file ID and modification time - let etag = format!("\"{}-{}\"", id, file.modified_at); - - // Check If-None-Match header for ETag validation (304 Not Modified) - if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) { - if let Ok(client_etag) = if_none_match.to_str() { + + let etag = format!("\"{}-{}\"", id, file_dto.modified_at); + + // ── ETag (304 Not Modified) ────────────────────────────────── + if let Some(inm) = headers.get(header::IF_NONE_MATCH) { + if let Ok(client_etag) = inm.to_str() { if client_etag == etag || client_etag == "*" { - tracing::debug!("ETag match for file {}, returning 304", file.name); return Response::builder() .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, &etag) @@ -630,69 +285,44 @@ impl FileHandler { } } } - - // ═══════════════════════════════════════════════════════════════════════ - // RANGE REQUESTS - For video seeking and resumable downloads - // ═══════════════════════════════════════════════════════════════════════ + + // ── Range Requests ─────────────────────────────────────────── if let Some(range_header) = headers.get(header::RANGE) { if let Ok(range_str) = range_header.to_str() { if let Ok(ranges) = parse_range_header(range_str) { - // Validate and get the first range (we only support single ranges) - let validated = ranges.validate(file.size); - + let validated = ranges.validate(file_dto.size); if let Ok(valid_ranges) = validated { if let Some(range) = valid_ranges.first() { let start = *range.start(); let end = *range.end(); let range_length = end - start + 1; - - tracing::info!( - "📡 RANGE REQUEST: {} bytes {}-{}/{}", - file.name, start, end, file.size - ); - - // Determine content disposition for range request - let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1"); - let disposition = if force_inline || - file.mime_type.starts_with("image/") || - file.mime_type == "application/pdf" || - file.mime_type.starts_with("video/") || - file.mime_type.starts_with("audio/") { - format!("inline; filename=\"{}\"", file.name) - } else { - format!("attachment; filename=\"{}\"", file.name) - }; - - // Use range streaming - match service.get_file_range_stream(&id, start, Some(end + 1)).await { + let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); + + match retrieval.get_file_range_stream(&id, start, Some(end + 1)).await { Ok(stream) => { - let pinned_stream = Box::into_pin(stream); - return Response::builder() .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_TYPE, &file.mime_type) + .header(header::CONTENT_TYPE, &file_dto.mime_type) .header(header::CONTENT_DISPOSITION, &disposition) .header(header::CONTENT_LENGTH, range_length) - .header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file.size)) + .header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_dto.size)) .header(header::ACCEPT_RANGES, "bytes") .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") - .body(Body::from_stream(pinned_stream)) + .body(Body::from_stream(Box::into_pin(stream))) .unwrap() .into_response(); - }, + } Err(err) => { tracing::error!("Error creating range stream: {}", err); - // Fall through to normal download on error + // fall through to normal download } } } } else { - // Range not satisfiable - tracing::warn!("Range not satisfiable: {} for file size {}", range_str, file.size); return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) - .header(header::CONTENT_RANGE, format!("bytes */{}", file.size)) + .header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size)) .body(Body::empty()) .unwrap() .into_response(); @@ -700,254 +330,329 @@ impl FileHandler { } } } - - // Determine content disposition - let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1"); - let disposition = if force_inline || - file.mime_type.starts_with("image/") || - file.mime_type == "application/pdf" || - file.mime_type.starts_with("video/") || - file.mime_type.starts_with("audio/") { - format!("inline; filename=\"{}\"", file.name) - } else { - format!("attachment; filename=\"{}\"", file.name) - }; - - // File size threshold for streaming (10MB) - const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024; - // Threshold for mmap vs streaming (100MB) - const MMAP_THRESHOLD: u64 = 100 * 1024 * 1024; - - // ═══════════════════════════════════════════════════════════════════════ - // TIER 0: WRITE-BEHIND CACHE - For recently uploaded small files - // Serves content directly from RAM if file was just uploaded - // ═══════════════════════════════════════════════════════════════════════ - let write_behind = &state.core.write_behind_cache; - if let Some(pending_content) = write_behind.get_pending(&id).await { - tracing::debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file.name, pending_content.len()); - - return Self::build_cached_response( - pending_content, - &file.mime_type, - &disposition, - &etag, - file.size, - ¶ms, - ).await; - } - - // ═══════════════════════════════════════════════════════════════════════ - // TIER 1: HOT CACHE - For small files (<10MB) - // With automatic WebP transcoding for images (30-50% smaller) - // ═══════════════════════════════════════════════════════════════════════ - if file.size < CACHE_THRESHOLD { - use crate::infrastructure::services::image_transcode_service::{ - ImageTranscodeService, BrowserCapabilities - }; - - // Check if browser supports WebP and image is transcodable - let accept_header = headers.get(header::ACCEPT) - .and_then(|v| v.to_str().ok()); - let browser_caps = BrowserCapabilities::from_accept_header(accept_header); - let should_transcode = browser_caps.supports_webp - && ImageTranscodeService::should_transcode(&file.mime_type, file.size) - && params.get("original").map_or(true, |v| v != "true" && v != "1"); - - // Check cache first - if let Some((cached_content, _cached_etag, _cached_content_type)) = content_cache.get(&id).await { - tracing::debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file.name, cached_content.len()); - - // Try WebP transcoding for cached content - if should_transcode { - let transcode_service = &state.core.image_transcode_service; - if let Some(format) = browser_caps.best_format() { - match transcode_service.get_transcoded( - &id, - &cached_content, - &file.mime_type, - format, - ).await { - Ok((transcoded, webp_mime, was_transcoded)) => { - if was_transcoded { - tracing::debug!("🖼️ WebP transcode: {} -> {} bytes ({:.0}% smaller)", - cached_content.len(), transcoded.len(), - (1.0 - transcoded.len() as f64 / cached_content.len() as f64) * 100.0 - ); - return Self::build_cached_response( - transcoded, - &webp_mime, - &disposition, - &etag, - file.size, - ¶ms, - ).await; - } - }, - Err(e) => { - tracing::debug!("WebP transcode failed, serving original: {}", e); - } - } - } - } - - return Self::build_cached_response( - cached_content, - &file.mime_type, - &disposition, - &etag, - file.size, - ¶ms, - ).await; - } - - // Cache miss - load from disk and cache - tracing::debug!("💾 TIER 1 Cache MISS: {} - loading from disk", file.name); - - match service.get_file_content(&id).await { - Ok(content) => { - let content_bytes = Bytes::from(content); - - // Store in cache for next time - content_cache.put( - id.clone(), - content_bytes.clone(), - etag.clone(), - file.mime_type.clone() - ).await; - - // Try WebP transcoding - if should_transcode { - let transcode_service = &state.core.image_transcode_service; - if let Some(format) = browser_caps.best_format() { - match transcode_service.get_transcoded( - &id, - &content_bytes, - &file.mime_type, - format, - ).await { - Ok((transcoded, webp_mime, was_transcoded)) => { - if was_transcoded { - tracing::info!("🖼️ WebP transcode: {} {} -> {} bytes ({:.0}% smaller)", - file.name, content_bytes.len(), transcoded.len(), - (1.0 - transcoded.len() as f64 / content_bytes.len() as f64) * 100.0 - ); - return Self::build_cached_response( - transcoded, - &webp_mime, - &disposition, - &etag, - file.size, - ¶ms, - ).await; - } - }, - Err(e) => { - tracing::debug!("WebP transcode failed, serving original: {}", e); - } - } - } - } - - return Self::build_cached_response( - content_bytes, - &file.mime_type, + + // ── Normal download (delegated to service) ─────────────────── + let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); + + let accept_webp = headers.get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .map_or(false, |a| a.contains("image/webp")); + let prefer_original = params.get("original").map_or(false, |v| v == "true" || v == "1"); + + match retrieval.get_file_optimized(&id, accept_webp, prefer_original).await { + Ok((_file, content)) => match content { + OptimizedFileContent::Bytes { data, mime_type, .. } => { + Self::build_cached_response( + data, + &mime_type, &disposition, &etag, - file.size, + file_dto.size, ¶ms, - ).await; - }, - Err(err) => { - tracing::error!("Error reading file content: {}", err); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error reading file: {}", err) - }))).into_response(); + &*state.core.compression_service, + ).await + .into_response() } - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // TIER 2: MMAP - For medium files (10-100MB) - // Zero-copy kernel memory mapping for optimal performance - // ═══════════════════════════════════════════════════════════════════════ - if file.size < MMAP_THRESHOLD { - tracing::info!("🗺️ TIER 2 MMAP: {} ({} MB)", file.name, file.size / (1024 * 1024)); - - match service.get_file_mmap(&id).await { - Ok(mmap_content) => { - return Response::builder() + OptimizedFileContent::Mmap(mmap_data) => { + Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &file.mime_type) + .header(header::CONTENT_TYPE, &file_dto.mime_type) .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, file.size) + .header(header::CONTENT_LENGTH, mmap_data.len()) .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") .header(header::ACCEPT_RANGES, "bytes") - .body(Body::from(mmap_content)) + .body(Body::from(mmap_data)) + .unwrap() + .into_response() + } + OptimizedFileContent::Stream(pinned_stream) => { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &file_dto.mime_type) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, file_dto.size) + .header(header::ETAG, &etag) + .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::from_stream(pinned_stream)) + .unwrap() + .into_response() + } + }, + Err(err) => { + tracing::error!("Error downloading file: {}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + "error": format!("Error reading file: {}", err) + }))).into_response() + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // LIST + // ═══════════════════════════════════════════════════════════════════════ + + /// Lists files, extracting `folder_id` from query parameters. + /// + /// Axum-compatible handler wrapper around [`Self::list_files`]. + pub async fn list_files_query( + State(state): State, + Query(params): Query>, + ) -> impl IntoResponse { + let folder_id = params.get("folder_id").map(|id| id.as_str()); + tracing::info!("API: Listing files with folder_id: {:?}", folder_id); + + let retrieval = &state.applications.file_retrieval_service; + match retrieval.list_files(folder_id).await { + Ok(files) => { + tracing::info!("Found {} files", files.len()); + (StatusCode::OK, Json(files)).into_response() + } + Err(err) => { + tracing::error!("Error listing files: {}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + "error": format!("Error listing files: {}", err) + }))).into_response() + } + } + } + + /// Uploads a file and generates thumbnails in the background for images. + /// + /// Delegates to [`Self::upload_file_with_cache`] and, on success, spawns + /// a background task to generate all thumbnail sizes. + pub async fn upload_file_with_thumbnails( + State(state): State, + multipart: Multipart, + ) -> impl IntoResponse { + // Use the smart upload handler + let response = Self::upload_file_with_cache(State(state.clone()), multipart).await; + + // Try to extract file info for thumbnail generation + if let Ok(body_bytes) = axum::body::to_bytes( + response.into_response().into_body(), + 10 * 1024, + ).await { + if let Ok(file_info) = serde_json::from_slice::(&body_bytes) { + if let (Some(file_id), Some(mime_type), Some(file_path_str)) = ( + file_info.get("id").and_then(|v| v.as_str()), + file_info.get("mime_type").and_then(|v| v.as_str()), + file_info.get("path").and_then(|v| v.as_str()), + ) { + // Generate thumbnails for images in background + if state.core.thumbnail_service.is_supported_image(mime_type) { + let file_id = file_id.to_string(); + let file_path_rel = file_path_str.to_string(); + let thumbnail_service = state.core.thumbnail_service.clone(); + let path_service = state.core.path_service.clone(); + + tokio::spawn(async move { + let file_path = path_service.get_root_path().join(&file_path_rel); + tracing::info!("🖼️ Generating thumbnails for: {}", file_id); + thumbnail_service.generate_all_sizes_background(file_id, file_path); + }); + } + + // Return the response + return Response::builder() + .status(StatusCode::CREATED) + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(Body::from(body_bytes)) .unwrap() .into_response(); - }, - Err(err) => { - tracing::warn!("MMAP failed, falling back to streaming: {}", err); - // Fall through to streaming } } } - - // ═══════════════════════════════════════════════════════════════════════ - // TIER 3: STREAMING - For very large files (≥100MB) - // Chunk-based streaming directly from disk to network - // ═══════════════════════════════════════════════════════════════════════ - tracing::info!("📡 TIER 3 STREAMING: {} ({} MB)", file.name, file.size / (1024 * 1024)); - - match service.get_file_stream(&id).await { - Ok(stream) => { - // The stream is already Box> + Send> - // axum's Body::from_stream accepts streams that yield Result, Error> - // We need to pin the boxed stream for use with Body::from_stream - let pinned_stream = Box::into_pin(stream); - + + // Fallback for errors + (StatusCode::INTERNAL_SERVER_ERROR, "Upload processing error").into_response() + } + + /// Lists files, optionally filtered by folder ID + pub async fn list_files( + State(state): State, + folder_id: Option<&str>, + ) -> impl IntoResponse { + tracing::info!("Listing files with folder_id: {:?}", folder_id); + + let retrieval = &state.applications.file_retrieval_service; + match retrieval.list_files(folder_id).await { + Ok(files) => { + tracing::info!("Found {} files through the service", files.len()); Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &file.mime_type) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, file.size) - .header(header::ETAG, &etag) - .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") - .header(header::ACCEPT_RANGES, "bytes") - .body(Body::from_stream(pinned_stream)) + .header("Cache-Control", "no-cache, no-store, must-revalidate") + .header("Pragma", "no-cache") + .header("Expires", "0") + .body(Body::from(serde_json::to_string(&files).unwrap())) .unwrap() - .into_response() - }, + } Err(err) => { - tracing::error!("Error creating file stream: {}", err); - - // Fallback to regular content loading if streaming fails - tracing::warn!("Falling back to content-based download for: {}", file.name); - - match service.get_file_content(&id).await { - Ok(content) => { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &file.mime_type) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, content.len()) - .header(header::ETAG, &etag) - .body(Body::from(content)) - .unwrap() - .into_response() - }, - Err(content_err) => { + tracing::error!("Error listing files: {}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + "error": err.to_string() + }))).into_response() + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // DELETE + // ═══════════════════════════════════════════════════════════════════════ + + /// Deletes a file (trash-first with dedup cleanup). + /// + /// All logic (trash fallback, dedup ref-count, hash computation) is handled + /// by `FileManagementUseCase::delete_with_cleanup`. + pub async fn delete_file( + State(state): State, + CurrentUserId(user_id): CurrentUserId, + Path(id): Path, + ) -> impl IntoResponse { + let mgmt = &state.applications.file_management_service; + + match mgmt.delete_with_cleanup(&id, &user_id).await { + Ok(was_trashed) => { + if was_trashed { + tracing::info!("File moved to trash: {}", id); + } else { + tracing::info!("File permanently deleted: {}", id); + } + StatusCode::NO_CONTENT.into_response() + } + Err(err) => { + tracing::error!("Error deleting file: {}", err); + let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, Json(serde_json::json!({ + "error": format!("Error deleting file: {}", err) + }))).into_response() + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // MOVE + // ═══════════════════════════════════════════════════════════════════════ + + /// Moves a file to a different folder + pub async fn move_file( + State(state): State, + Path(id): Path, + Json(payload): Json, + ) -> impl IntoResponse { + tracing::info!("Moving file {} to folder {:?}", id, payload.folder_id); + + let retrieval = &state.applications.file_retrieval_service; + let mgmt = &state.applications.file_management_service; + + match retrieval.get_file(&id).await { + Ok(_) => { + match mgmt.move_file(&id, payload.folder_id).await { + Ok(file) => (StatusCode::OK, Json(file)).into_response(), + Err(err) => { + tracing::error!("Error moving file: {}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error reading file: {}", content_err) + "error": format!("Error moving file: {}", err) }))).into_response() } } } + Err(err) => { + tracing::error!("File not found for move: {}", err); + (StatusCode::NOT_FOUND, Json(serde_json::json!({ + "error": format!("File with ID {} does not exist", id) + }))).into_response() + } } } - - /// Build response for cached/small files with optional compression + + /// Moves a file to a different folder (simplified payload accepting generic JSON) + pub async fn move_file_simple( + State(state): State, + Path(id): Path, + Json(payload): Json, + ) -> impl IntoResponse { + let folder_id = payload + .get("folder_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mgmt = &state.applications.file_management_service; + match mgmt.move_file(&id, folder_id).await { + Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), + Err(err) => { + tracing::error!("Error moving file: {}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + "error": format!("Error moving file: {}", err) + }))).into_response() + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // PRIVATE HELPERS + // ═══════════════════════════════════════════════════════════════════════ + + /// Combine chunks into a single Vec. + fn combine_chunks(chunks: Vec, total_size: usize) -> Vec { + if chunks.len() == 1 { + chunks.into_iter().next().unwrap().to_vec() + } else { + let mut combined = Vec::with_capacity(total_size); + for chunk in chunks { + combined.extend_from_slice(&chunk); + } + combined + } + } + + /// Build a Content-Disposition header value. + fn content_disposition(name: &str, mime: &str, params: &HashMap) -> String { + let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1"); + if force_inline + || mime.starts_with("image/") + || mime == "application/pdf" + || mime.starts_with("video/") + || mime.starts_with("audio/") + { + format!("inline; filename=\"{}\"", name) + } else { + format!("attachment; filename=\"{}\"", name) + } + } + + /// Build a 201 Created JSON response. + fn created_json_response(file: &crate::application::dtos::file_dto::FileDto) -> Response { + Response::builder() + .status(StatusCode::CREATED) + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(Body::from(serde_json::to_string(file).unwrap())) + .unwrap() + } + + /// Build error response for DomainError. + fn domain_error_response(err: crate::common::errors::DomainError) -> Response { + let status = match err.kind { + crate::common::errors::ErrorKind::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "error": format!("Error: {}", err) }).to_string(), + )) + .unwrap() + } + + /// Build response for cached/small files with optional compression. async fn build_cached_response( content: Bytes, mime_type: &str, @@ -955,13 +660,12 @@ impl FileHandler { etag: &str, file_size: u64, params: &HashMap, + compression_service: &dyn CompressionPort, ) -> Response { - // Check if compression is requested let compression_param = params.get("compress").map(|v| v.as_str()); let force_compress = compression_param == Some("true") || compression_param == Some("1"); let force_no_compress = compression_param == Some("false") || compression_param == Some("0"); - - let compression_service = GzipCompressionService::new(); + let should_compress = if force_no_compress { false } else if force_compress { @@ -969,37 +673,30 @@ impl FileHandler { } else { compression_service.should_compress(mime_type, file_size) }; - + let compression_level = match params.get("compression_level").map(|v| v.as_str()) { Some("fast") => CompressionLevel::Fast, Some("best") => CompressionLevel::Best, _ => CompressionLevel::Default, }; - + let builder = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_DISPOSITION, disposition) .header(header::ETAG, etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") .header(header::VARY, "Accept-Encoding"); - + if should_compress { match compression_service.compress_data(&content.to_vec(), compression_level).await { Ok(compressed) => { - tracing::debug!( - "Compressed {}KB → {}KB ({:.1}x)", - content.len() / 1024, - compressed.len() / 1024, - content.len() as f64 / compressed.len().max(1) as f64 - ); - builder .header(header::CONTENT_TYPE, mime_type) .header(header::CONTENT_ENCODING, "gzip") .header(header::CONTENT_LENGTH, compressed.len()) .body(Body::from(compressed)) .unwrap() - }, + } Err(_) => { builder .header(header::CONTENT_TYPE, mime_type) @@ -1016,214 +713,6 @@ impl FileHandler { .unwrap() } } - - /// Lists files, optionally filtered by folder ID - pub async fn list_files( - State(service): State, - folder_id: Option<&str>, - ) -> impl IntoResponse { - tracing::info!("Listing files with folder_id: {:?}", folder_id); - - // Simply use the file service to list files - match service.list_files(folder_id).await { - Ok(files) => { - // Log success for debugging purposes - tracing::info!("Found {} files through the service", files.len()); - - if !files.is_empty() { - tracing::info!("First file in service list: {} (ID: {})", - files[0].name, files[0].id); - } else { - tracing::info!("No files found in folder through service"); - } - - // Devolver respuesta con cabeceras para evitar caché del navegador - let response = Response::builder() - .status(StatusCode::OK) - .header("Cache-Control", "no-cache, no-store, must-revalidate") - .header("Pragma", "no-cache") - .header("Expires", "0") - .body(axum::body::Body::from(serde_json::to_string(&files).unwrap())) - .unwrap(); - - response - }, - Err(err) => { - tracing::error!("Error listing files through service: {}", err); - - let status = StatusCode::INTERNAL_SERVER_ERROR; - - // Return a JSON error response - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() - } - } - } - - /// Deletes a file (with trash support and dedup reference counting) - /// - /// When a file is deleted: - /// 1. Try to move to trash (soft delete) - /// 2. If trash fails, do permanent delete - /// 3. Decrement dedup reference count for the content hash - pub async fn delete_file( - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path, - ) -> impl IntoResponse { - let dedup_service = &state.core.dedup_service; - - // Get file info first to calculate content hash for dedup - let file_info = state.applications.file_service.get_file(&id).await.ok(); - let content_hash: Option = if file_info.is_some() { - // Try to read file content and calculate hash for dedup tracking - match state.applications.file_service.get_file_content(&id).await { - Ok(content) => { - let hash = crate::infrastructure::services::dedup_service::DedupService::hash_bytes(&content); - tracing::debug!("🔗 DEDUP: File {} has content hash: {}", id, &hash[..12]); - Some(hash) - }, - Err(e) => { - tracing::debug!("Could not read file content for dedup: {}", e); - None - } - } - } else { - None - }; - - // Check if trash service is available - if let Some(trash_service) = &state.trash_service { - tracing::info!("Moving file to trash: {}", id); - - // Debug logs to track trash components - tracing::debug!("Trash service type: {}", std::any::type_name_of_val(&*trash_service)); - // User ID extracted from authenticated token via CurrentUserId - tracing::info!("Using authenticated user ID: {}", user_id); - - // Try to move to trash first - add more detailed logging - tracing::info!("About to call trash_service.move_to_trash with id={}, type=file", id); - match trash_service.move_to_trash(&id, "file", &user_id).await { - Ok(_) => { - tracing::info!("File successfully moved to trash: {}", id); - - // Decrement dedup reference count (file is in trash but content might be shared) - if let Some(hash) = &content_hash { - match dedup_service.remove_reference(hash).await { - Ok(deleted) => { - if deleted { - tracing::info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]); - } else { - tracing::debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]); - } - }, - Err(e) => { - tracing::warn!("⚠️ DEDUP: Failed to decrement reference: {}", e); - } - } - } - - // Note: Use 204 No Content for consistency with DELETE operations - return StatusCode::NO_CONTENT.into_response(); - }, - Err(err) => { - tracing::error!("Could not move file to trash: {:?}", err); - tracing::error!("Error kind: {:?}, Error details: {}", err.kind, err); - tracing::warn!("Could not move file to trash, falling back to permanent delete: {}", err); - // Fall through to regular delete if trash fails - } - } - } else { - tracing::warn!("Trash service not available, using permanent delete"); - } - - // Fallback to permanent delete if trash is unavailable or failed - tracing::warn!("Falling back to permanent delete for file: {}", id); - let file_service = &state.applications.file_service; - match file_service.delete_file(&id).await { - Ok(_) => { - tracing::info!("File permanently deleted: {}", id); - - // Decrement dedup reference count for permanent delete - if let Some(hash) = &content_hash { - match dedup_service.remove_reference(hash).await { - Ok(deleted) => { - if deleted { - tracing::info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]); - } else { - tracing::debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]); - } - }, - Err(e) => { - tracing::warn!("⚠️ DEDUP: Failed to decrement reference: {}", e); - } - } - } - - // CRITICAL FIX: Return status code that matches the API expectations (204 No Content) - // This ensures the client knows the operation was successful - StatusCode::NO_CONTENT.into_response() - }, - Err(err) => { - tracing::error!("Error deleting file: {}", err); - - let status = match err.kind { - crate::common::errors::ErrorKind::NotFound => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - - (status, Json(serde_json::json!({ - "error": format!("Error deleting file: {}", err) - }))).into_response() - } - } - } - - /// Moves a file to a different folder - pub async fn move_file( - State(service): State, - Path(id): Path, - Json(payload): Json, - ) -> impl IntoResponse { - tracing::info!("API request: Moving file with ID: {} to folder: {:?}", id, payload.folder_id); - - // First verify if the file exists - match service.get_file(&id).await { - Ok(file) => { - tracing::info!("File found: {} (ID: {}), proceeding with move operation", file.name, id); - - // For target folders, we trust that the move operation will verify their existence - if let Some(folder_id) = &payload.folder_id { - tracing::info!("Will attempt to move to folder: {}", folder_id); - } - - // Proceed with the move operation - match service.move_file(&id, payload.folder_id).await { - Ok(file) => { - tracing::info!("File moved successfully: {} (ID: {})", file.name, file.id); - (StatusCode::OK, Json(file)).into_response() - }, - Err(err) => { - // Simplify error handling - let status = StatusCode::INTERNAL_SERVER_ERROR; - tracing::error!("Error moving file: {}", err); - - (status, Json(serde_json::json!({ - "error": format!("Error moving file: {}", err) - }))).into_response() - } - } - }, - Err(err) => { - tracing::error!("Error finding file to move - does not exist: {} (ID: {})", err, id); - (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("The file with ID: {} does not exist", id), - "code": StatusCode::NOT_FOUND.as_u16() - }))).into_response() - } - } - } } /// Payload for moving a file diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index b22dc330..690fd9ec 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -14,7 +14,6 @@ use crate::common::errors::ErrorKind; use crate::application::ports::inbound::FolderUseCase; use crate::common::di::AppState as GlobalAppState; use crate::interfaces::middleware::auth::AuthUser; -use crate::infrastructure::services::zip_service::ZipService; type AppState = Arc; @@ -59,6 +58,38 @@ impl FolderHandler { } } + /// Lists root folders (no parent ID) + pub async fn list_root_folders( + State(service): State, + ) -> impl IntoResponse { + Self::list_folders(State(service), None).await + } + + /// Lists contents of a specific folder by its ID + pub async fn list_folder_contents( + State(service): State, + Path(id): Path, + ) -> impl IntoResponse { + Self::list_folders(State(service), Some(&id)).await + } + + /// Lists root folders with pagination support + pub async fn list_root_folders_paginated( + State(service): State, + pagination: Query, + ) -> impl IntoResponse { + Self::list_folders_paginated(State(service), pagination, None).await + } + + /// Lists contents of a specific folder with pagination + pub async fn list_folder_contents_paginated( + State(service): State, + Path(id): Path, + pagination: Query, + ) -> impl IntoResponse { + Self::list_folders_paginated(State(service), pagination, Some(&id)).await + } + /// Lists folders, optionally filtered by parent ID pub async fn list_folders( State(service): State, @@ -226,17 +257,13 @@ impl FolderHandler { // Get folder information first to check it exists and get name let folder_service = &state.applications.folder_service; - let file_service = &state.applications.file_service; match folder_service.get_folder(&id).await { Ok(folder) => { tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); - // Create ZIP service with the required services - let zip_service = ZipService::new( - file_service.clone(), - folder_service.clone() - ); + // Use ZIP service from DI container + let zip_service = &state.core.zip_service; // Create the ZIP file match zip_service.create_folder_zip(&id, &folder.name).await { diff --git a/src/interfaces/api/handlers/i18n_handler.rs b/src/interfaces/api/handlers/i18n_handler.rs index 5a241f48..775fded6 100644 --- a/src/interfaces/api/handlers/i18n_handler.rs +++ b/src/interfaces/api/handlers/i18n_handler.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use axum::{ - extract::{State, Query}, + extract::{State, Query, Path}, http::StatusCode, response::IntoResponse, Json, @@ -75,6 +75,14 @@ impl I18nHandler { } } + /// Gets all translations for a locale (Axum-compatible: extracts locale from path) + pub async fn get_translations_by_locale( + State(service): State, + Path(locale_code): Path, + ) -> impl IntoResponse { + Self::get_translations(State(service), locale_code).await + } + /// Gets all translations for a locale pub async fn get_translations( State(_service): State, diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index 311ba21e..e4bb589c 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -15,6 +15,7 @@ use crate::{ ports::share_ports::ShareUseCase }, common::errors::ErrorKind, + interfaces::middleware::auth::AuthUser, }; #[derive(Debug, Deserialize)] @@ -31,10 +32,10 @@ pub struct VerifyPasswordRequest { /// Create a new shared link pub async fn create_shared_link( State(share_use_case): State>, + auth_user: AuthUser, Json(dto): Json, ) -> impl IntoResponse { - // For now, we'll use a default user ID until auth is implemented - let user_id = "default-user"; + let user_id = &auth_user.id; match share_use_case.create_shared_link(&user_id, dto).await { Ok(share) => (StatusCode::CREATED, Json(share)).into_response(), Err(err) => { @@ -68,10 +69,10 @@ pub async fn get_shared_link( /// Get all shared links created by the current user pub async fn get_user_shares( State(share_use_case): State>, + auth_user: AuthUser, Query(query): Query, ) -> impl IntoResponse { - // For now, we'll use a default user ID until auth is implemented - let user_id = "default-user"; + let user_id = &auth_user.id; let page = query.page.unwrap_or(1); let per_page = query.per_page.unwrap_or(20); diff --git a/src/interfaces/api/handlers/trash_handler.rs b/src/interfaces/api/handlers/trash_handler.rs index a685168f..9810afb7 100644 --- a/src/interfaces/api/handlers/trash_handler.rs +++ b/src/interfaces/api/handlers/trash_handler.rs @@ -2,7 +2,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use serde_json::json; -use tracing::{debug, error, instrument}; +use tracing::{debug, error, warn, instrument}; // use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; @@ -14,7 +14,12 @@ pub async fn get_trash_items( State(state): State, auth_user: AuthUser, ) -> (StatusCode, Json) { - debug!("Solicitud para listar elementos en papelera para usuario {}", auth_user.id); + // SECURITY: Always use the authenticated user's ID from the JWT token. + // Never allow user ID override via query parameters to prevent + // privilege escalation attacks. + let effective_user = auth_user.id.clone(); + + debug!("Solicitud para listar elementos en papelera para usuario {}", effective_user); let trash_service = match state.trash_service.as_ref() { Some(service) => service, @@ -25,7 +30,7 @@ pub async fn get_trash_items( } }; - let result = trash_service.get_trash_items(&auth_user.id).await; + let result = trash_service.get_trash_items(&effective_user).await; match result { Ok(items) => { @@ -184,6 +189,16 @@ pub async fn restore_from_trash( }))) }, Err(e) => { + let err_str = format!("{}", e); + // If item not found, report success (it was already restored or removed) + if err_str.contains("not found") || err_str.contains("NotFound") { + warn!("Item not found in trash, but reporting success: {}", trash_id); + return (StatusCode::OK, Json(json!({ + "success": true, + "message": "Item restored (or was already removed from trash)" + }))); + } + error!("Error al restaurar elemento de papelera: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Error restoring item from trash: {}", e) @@ -220,6 +235,16 @@ pub async fn delete_permanently( }))) }, Err(e) => { + let err_str = format!("{}", e); + // If item not found, report success (it was already deleted) + if err_str.contains("not found") || err_str.contains("NotFound") { + warn!("Item not found in trash, but reporting success: {}", trash_id); + return (StatusCode::OK, Json(json!({ + "success": true, + "message": "Item deleted (or was already removed from trash)" + }))); + } + error!("Error al eliminar permanentemente elemento: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Error deleting item permanently: {}", e) diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index d92a1c08..6fb72740 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -171,7 +171,7 @@ async fn handle_propfind( // Get folder service from state let folder_service = &state.applications.folder_service; - let file_service = &state.applications.file_service; + let file_retrieval_service = &state.applications.file_retrieval_service; // Determine base HREF let base_href = format!("/webdav/{}/", path); @@ -183,7 +183,7 @@ async fn handle_propfind( AppError::internal_error(format!("Failed to get subfolders: {}", e)) })?; - let files = file_service.list_files(None).await.map_err(|e| { + let files = file_retrieval_service.list_files(None).await.map_err(|e| { AppError::internal_error(format!("Failed to get files: {}", e)) })?; @@ -224,7 +224,7 @@ async fn handle_propfind( if let Ok(folder) = folder_result { // Path is a folder let files = if depth != "0" { - file_service.list_files(Some(&folder.id)).await.map_err(|e| { + file_retrieval_service.list_files(Some(&folder.id)).await.map_err(|e| { AppError::internal_error(format!("Failed to get files: {}", e)) })? } else { @@ -260,7 +260,7 @@ async fn handle_propfind( .unwrap()) } else { // Check if path is a file - let file_result = file_service.get_file_by_path(&path).await; + let file_result = file_retrieval_service.get_file_by_path(&path).await; if let Ok(file) = file_result { // Path is a file @@ -395,7 +395,6 @@ async fn handle_get( })?; // Get file service from state - let file_service = &state.applications.file_service; let file_retrieval_service = &state.applications.file_retrieval_service; // Check if path is empty (root folder) @@ -404,7 +403,7 @@ async fn handle_get( } // Get file metadata - let file = file_service.get_file_by_path(&path).await.map_err(|_e| { + let file = file_retrieval_service.get_file_by_path(&path).await.map_err(|_e| { AppError::not_found(format!("File not found: {}", path)) })?; @@ -467,7 +466,7 @@ async fn handle_put( }; // Get file service from state - let file_service = &state.applications.file_service; + let file_upload_service = &state.applications.file_upload_service; // Check if path is empty (root folder) if path.is_empty() || path == "/" { @@ -475,7 +474,7 @@ async fn handle_put( } // Extract content type before consuming the request - let content_type = req.headers() + let _content_type = req.headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") @@ -495,38 +494,19 @@ async fn handle_put( }; // Check if file exists - let file_exists = file_service.get_file_by_path(&path).await.is_ok(); + let file_exists = file_upload_service.update_file(&path, &body_bytes).await; - if file_exists { - // Update existing file - file_service.update_file(&path, &body_bytes).await.map_err(|e| { - AppError::internal_error(format!("Failed to update file: {}", e)) - })?; - - Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - .unwrap()) - } else { - // Create new file - // Extract filename from path - let filename = path.split('/').last().unwrap_or("unnamed"); - - // Get parent folder path - let parent_path = if let Some(idx) = path.rfind('/') { - &path[..idx] - } else { - "" - }; - - file_service.create_file(parent_path, filename, &body_bytes, &content_type).await.map_err(|e| { - AppError::internal_error(format!("Failed to create file: {}", e)) - })?; - - Ok(Response::builder() - .status(StatusCode::CREATED) - .body(Body::empty()) - .unwrap()) + match file_exists { + Ok(_) => { + // update_file handles both update and create-if-not-found + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap()) + } + Err(e) => { + Err(AppError::internal_error(format!("Failed to put file: {}", e))) + } } } @@ -658,7 +638,8 @@ async fn handle_delete( })?; // Get services from state - let file_service = &state.applications.file_service; + let file_retrieval_service = &state.applications.file_retrieval_service; + let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; // Check if path is empty (root folder) @@ -676,11 +657,11 @@ async fn handle_delete( })?; } else { // Try to delete file - let file = file_service.get_file_by_path(&path).await.map_err(|_e| { + let file = file_retrieval_service.get_file_by_path(&path).await.map_err(|_e| { AppError::not_found(format!("Resource not found: {}", path)) })?; - file_service.delete_file(&file.id).await.map_err(|e| { + file_management_service.delete_file(&file.id).await.map_err(|e| { AppError::internal_error(format!("Failed to delete file: {}", e)) })?; } @@ -736,7 +717,8 @@ async fn handle_move( }; // Get services from state - let file_service = &state.applications.file_service; + let file_retrieval_service = &state.applications.file_retrieval_service; + let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; // Check if source is a folder @@ -778,7 +760,7 @@ async fn handle_move( } } else { // Try to move file - let file = file_service.get_file_by_path(&source_path).await.map_err(|_e| { + let file = file_retrieval_service.get_file_by_path(&source_path).await.map_err(|_e| { AppError::not_found(format!("Resource not found: {}", source_path)) })?; @@ -788,7 +770,7 @@ async fn handle_move( "" }; - file_service.move_file(&file.id, Some(dest_parent_path.to_string())).await.map_err(|e| { + file_management_service.move_file(&file.id, Some(dest_parent_path.to_string())).await.map_err(|e| { AppError::internal_error(format!("Failed to move file: {}", e)) })?; } @@ -850,9 +832,9 @@ async fn handle_copy( .unwrap_or("infinity"); // Get services from state - let file_service = &state.applications.file_service; - let folder_service = &state.applications.folder_service; let file_retrieval_service = &state.applications.file_retrieval_service; + let file_upload_service = &state.applications.file_upload_service; + let folder_service = &state.applications.folder_service; // Check if source is a folder let folder_result = folder_service.get_folder_by_path(&source_path).await; @@ -889,25 +871,23 @@ async fn handle_copy( if recursive { // Copy subfolders and files (simplified implementation) - let files = file_service.list_files(Some(&folder.id)).await.map_err(|e| { + let files = file_retrieval_service.list_files(Some(&folder.id)).await.map_err(|e| { AppError::internal_error(format!("Failed to list files: {}", e)) })?; for file in files { // Get file content - if let Ok(file_source) = file_service.get_file_by_path(&format!("{}/{}", source_path, file.name)).await { - if let Ok(content) = file_retrieval_service.get_file_content(&file_source.id).await { - // Create new file in destination - file_service.create_file(&destination_path, &file.name, &content, &file.mime_type).await.map_err(|e| { - AppError::internal_error(format!("Failed to copy file {}: {}", file.name, e)) - })?; - } + if let Ok(content) = file_retrieval_service.get_file_content(&file.id).await { + // Create new file in destination + file_upload_service.create_file(&destination_path, &file.name, &content, &file.mime_type).await.map_err(|e| { + AppError::internal_error(format!("Failed to copy file {}: {}", file.name, e)) + })?; } } } } else { // Try to copy file - let file = file_service.get_file_by_path(&source_path).await.map_err(|_e| { + let file = file_retrieval_service.get_file_by_path(&source_path).await.map_err(|_e| { AppError::not_found(format!("Resource not found: {}", source_path)) })?; @@ -925,7 +905,7 @@ async fn handle_copy( }; // Create new file in destination - file_service.create_file(dest_parent_path, dest_filename, &content, &file.mime_type).await.map_err(|e| { + file_upload_service.create_file(dest_parent_path, dest_filename, &content, &file.mime_type).await.map_err(|e| { AppError::internal_error(format!("Failed to copy file: {}", e)) })?; } diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 4880ded2..ea8395b0 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -1,4 +1,5 @@ pub mod handlers; pub mod routes; -pub use routes::create_api_routes; \ No newline at end of file +pub use routes::create_api_routes; +pub use routes::create_public_api_routes; \ No newline at end of file diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 90b68be3..10d7eb03 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -1,192 +1,85 @@ use std::sync::Arc; -use std::collections::HashMap; use axum::{ routing::{get, post, put, delete}, Router, - extract::{State, Query, Path}, - http::StatusCode, - Json, - response::IntoResponse, }; use tower_http::{ compression::CompressionLayer, trace::TraceLayer, }; -use serde_json::json; -use crate::common::config::AppConfig; use crate::common::di::AppState; -use crate::interfaces::middleware::auth::CurrentUserId; use crate::interfaces::middleware::cache::{HttpCache, start_cache_cleanup_task}; -use crate::application::services::folder_service::FolderService; -use crate::application::services::file_service::FileService; -use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::batch_operations::BatchOperationService; -use crate::application::ports::trash_ports::TrashUseCase; -use crate::application::ports::inbound::SearchUseCase; -use crate::application::ports::share_ports::ShareUseCase; -use crate::application::ports::favorites_ports::FavoritesUseCase; -use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::interfaces::api::handlers::folder_handler::FolderHandler; use crate::interfaces::api::handlers::file_handler::FileHandler; use crate::interfaces::api::handlers::i18n_handler::I18nHandler; use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; -// Eliminamos la importación de ShareHandler ya que ahora usamos directamente el servicio +use crate::interfaces::api::handlers::trash_handler; use crate::interfaces::api::handlers::batch_handler::{ self, BatchHandlerState }; -use crate::application::dtos::pagination::PaginationRequestDto; -/// Creates API routes for the application -pub fn create_api_routes( - folder_service: Arc, - file_service: Arc, - i18n_service: Option>, - trash_service: Option>, - search_service: Option>, - share_service: Option>, - favorites_service: Option>, - recent_service: Option>, -) -> Router { - // Create a simplified AppState for the trash view - // Setup required components for repository construction - let path_service = Arc::new(crate::infrastructure::services::path_service::PathService::new(std::path::PathBuf::from("./storage"))); - let storage_mediator = Arc::new(crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub()); - let id_mapping_service = Arc::new(crate::infrastructure::services::id_mapping_service::IdMappingService::dummy()); - let path_resolver = Arc::new(crate::infrastructure::repositories::file_path_resolver::FilePathResolver::new( - path_service.clone(), - storage_mediator.clone(), - id_mapping_service.clone() - )); - let metadata_cache = Arc::new(crate::infrastructure::services::file_metadata_cache::FileMetadataCache::new( - crate::common::config::AppConfig::default(), - 1000 // Default max entries - )); - - // Create file and folder repositories - let file_repository = Arc::new(crate::infrastructure::repositories::file_fs_repository::FileFsRepository::new( - std::path::PathBuf::from("./storage"), - storage_mediator.clone(), - id_mapping_service.clone(), - path_service.clone(), - metadata_cache.clone(), - )); - - let folder_repository = Arc::new(crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository::new( - std::path::PathBuf::from("./storage"), - storage_mediator.clone(), - id_mapping_service.clone(), - path_service.clone(), - )); - - // Create concrete id_mapping_service for optimizer - let id_mapping_service_concrete = Arc::new(crate::infrastructure::services::id_mapping_service::IdMappingService::dummy()); - let id_mapping_optimizer = Arc::new(crate::infrastructure::services::id_mapping_optimizer::IdMappingOptimizer::new(id_mapping_service_concrete.clone())); - - // Create dummy thumbnail service for routes - let thumbnail_service = Arc::new( - crate::infrastructure::services::thumbnail_service::ThumbnailService::new( - &std::path::PathBuf::from("./storage"), - 100, - 10 * 1024 * 1024, - ) - ); - - // Create dummy write-behind cache for routes - let write_behind_cache = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); - - // Create dummy chunked upload service for routes - let chunked_upload_service = Arc::new( - crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( - std::path::PathBuf::from("./storage/.uploads") - ) - ); - - // Create dummy image transcode service for routes - let image_transcode_service = Arc::new( - crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( - &std::path::PathBuf::from("./storage"), - 100, - 10 * 1024 * 1024, - ) - ); - - // Create dummy dedup service for routes - let dedup_service = Arc::new( - crate::infrastructure::services::dedup_service::DedupService::new( - &std::path::PathBuf::from("./storage") - ) - ); - - let mut app_state = crate::common::di::AppState { - core: crate::common::di::CoreServices { - path_service: path_service.clone(), - cache_manager: Arc::new(crate::infrastructure::services::cache_manager::StorageCacheManager::default()), - file_content_cache: Arc::new(crate::infrastructure::services::file_content_cache::FileContentCache::default()), - id_mapping_service: id_mapping_service.clone(), - file_id_mapping_service: id_mapping_service_concrete.clone(), - id_mapping_optimizer: id_mapping_optimizer.clone(), - thumbnail_service: thumbnail_service.clone(), - write_behind_cache: write_behind_cache.clone(), - chunked_upload_service: chunked_upload_service.clone(), - image_transcode_service: image_transcode_service.clone(), - dedup_service: dedup_service.clone(), - config: crate::common::config::AppConfig::default(), - }, - repositories: crate::common::di::RepositoryServices { - folder_repository: folder_repository.clone(), - file_repository: file_repository.clone(), - file_read_repository: Arc::new(crate::infrastructure::repositories::FileFsReadRepository::default_stub()), - file_write_repository: Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub()), - i18n_repository: Arc::new(crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService::dummy()), - storage_mediator: storage_mediator.clone(), - metadata_manager: Arc::new(crate::infrastructure::repositories::FileMetadataManager::default()), - path_resolver: path_resolver.clone(), - metadata_cache: metadata_cache.clone(), - trash_repository: None, // This is OK to be None since we use the trash_service directly - }, - storage_usage_service: None, - applications: crate::common::di::ApplicationServices { - folder_service_concrete: folder_service.clone(), - file_service_concrete: file_service.clone(), - folder_service: folder_service.clone(), - file_service: file_service.clone(), - file_upload_service: Arc::new(crate::application::services::file_upload_service::FileUploadService::new( - Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub()) - )), - file_retrieval_service: Arc::new(crate::application::services::file_retrieval_service::FileRetrievalService::new( - Arc::new(crate::infrastructure::repositories::FileFsReadRepository::default_stub()) - )), - file_management_service: Arc::new(crate::application::services::file_management_service::FileManagementService::new( - Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub()) - )), - file_use_case_factory: Arc::new(crate::application::services::file_use_case_factory::AppFileUseCaseFactory::new( - Arc::new(crate::infrastructure::repositories::FileFsReadRepository::default_stub()), - Arc::new(crate::infrastructure::repositories::FileFsWriteRepository::default_stub()) - )), - i18n_service: i18n_service.clone().unwrap_or_else(|| - Arc::new(crate::application::services::i18n_application_service::I18nApplicationService::dummy()) - ), - trash_service: trash_service.clone(), // Include the trash service here too for consistency - search_service: search_service.clone(), // Include the search service - share_service: share_service.clone(), // Include the share service - favorites_service: favorites_service.clone(), // Include the favorites service - recent_service: recent_service.clone(), // Include the recent service - }, - db_pool: None, - auth_service: None, - trash_service: trash_service.clone(), // This is the important part - include the trash service - share_service: share_service.clone(), // Include the share service for routes - favorites_service: favorites_service.clone(), // Include the favorites service for routes - recent_service: recent_service.clone(), // Include the recent service for routes - calendar_service: None, // Adding missing field - contact_service: None // Adding missing field - }; +/// Creates public API routes that should NOT require authentication. +/// +/// Currently this includes: +/// - `/s/{token}` — public access to shared items via share link +/// - `/s/{token}/verify` — password verification for protected share links +/// - `/i18n/*` — internationalization/translation endpoints +pub fn create_public_api_routes(app_state: &AppState) -> Router { + let share_service = app_state.share_service.clone(); + let i18n_service = Some(app_state.applications.i18n_service.clone()); + + let mut router = Router::new(); + + // Public share access routes — no auth required + if let Some(share_service) = share_service { + use crate::interfaces::api::handlers::share_handler; + + let public_share_router = Router::new() + .route("/{token}", get(share_handler::access_shared_item)) + .route("/{token}/verify", post(share_handler::verify_shared_item_password)) + .with_state(share_service); + + router = router.nest("/s", public_share_router); + } + + // i18n routes — no auth required (localization should be available before login) + if let Some(i18n_service) = i18n_service { + let i18n_router = Router::new() + .route("/locales", get(I18nHandler::get_locales)) + .route("/translate", get(I18nHandler::translate)) + .route("/locales/{locale_code}", get(I18nHandler::get_translations_by_locale)) + .with_state(i18n_service); + + router = router.nest("/i18n", i18n_router); + } + + router +} + +/// Creates protected API routes for the application. +/// +/// These routes require authentication when auth is enabled. +/// Receives the fully-assembled `AppState` and extracts all needed services +/// from it, avoiding a long parameter list. +pub fn create_api_routes(app_state: &AppState) -> Router { + // Extract services from the pre-built AppState + let folder_service = app_state.applications.folder_service_concrete.clone(); + let file_retrieval_service = app_state.applications.file_retrieval_service.clone(); + let file_management_service = app_state.applications.file_management_service.clone(); + let trash_service = app_state.trash_service.clone(); + let search_service = app_state.applications.search_service.clone(); + let share_service = app_state.share_service.clone(); + let favorites_service = app_state.favorites_service.clone(); + let recent_service = app_state.recent_service.clone(); + // Inicializar el servicio de operaciones por lotes let batch_service = Arc::new(BatchOperationService::default( - file_service.clone(), + file_retrieval_service.clone(), + file_management_service.clone(), folder_service.clone() )); @@ -209,33 +102,11 @@ pub fn create_api_routes( // Create the basic folders router with service operations let folders_basic_router = Router::new() .route("/", post(FolderHandler::create_folder)) - .route("/", get(|State(service): State>| async move { - // No parent ID means list root folders - FolderHandler::list_folders(State(service), None).await - })) - .route("/paginated", get(| - State(service): State>, - pagination: Query - | async move { - // Paginación para carpetas raíz (sin parent) - FolderHandler::list_folders_paginated(State(service), pagination, None).await - })) + .route("/", get(FolderHandler::list_root_folders)) + .route("/paginated", get(FolderHandler::list_root_folders_paginated)) .route("/{id}", get(FolderHandler::get_folder)) - .route("/{id}/contents", get(| - State(service): State>, - Path(id): Path - | async move { - // Listar contenido de una carpeta por su ID - FolderHandler::list_folders(State(service), Some(&id)).await - })) - .route("/{id}/contents/paginated", get(| - State(service): State>, - Path(id): Path, - pagination: Query - | async move { - // Listar contenido paginado de una carpeta por su ID - FolderHandler::list_folders_paginated(State(service), pagination, Some(&id)).await - })) + .route("/{id}/contents", get(FolderHandler::list_folder_contents)) + .route("/{id}/contents/paginated", get(FolderHandler::list_folder_contents_paginated)) .route("/{id}/rename", put(FolderHandler::rename_folder)) .route("/{id}/move", put(FolderHandler::move_folder)) .with_state(folder_service.clone()); @@ -245,144 +116,25 @@ pub fn create_api_routes( .route("/{id}/download", get(FolderHandler::download_folder_zip)) .with_state(app_state.clone()); - // Create folder operations that use trash separately + // Create folder operations that use trash (requires full AppState) let folders_ops_router = Router::new() - .route("/{id}", delete(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path - | async move { - // Try to use trash service if available - if let Some(trash_service) = &state.trash_service { - tracing::info!("Moving folder to trash: {}", id); - - match trash_service.move_to_trash(&id, "folder", &user_id).await { - Ok(_) => { - tracing::info!("Folder successfully moved to trash: {}", id); - return StatusCode::NO_CONTENT.into_response(); - }, - Err(err) => { - tracing::warn!("Could not move folder to trash, falling back to permanent delete: {}", err); - // Fall through to regular delete - } - } - } - - // Fallback to permanent delete - let folder_service = &state.applications.folder_service; - match folder_service.delete_folder(&id).await { - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - })); + .route("/{id}", delete(FolderHandler::delete_folder_with_trash)); // Merge the routers let folders_router = folders_basic_router.merge(folders_ops_router).merge(folder_zip_router); // Create file routes for basic operations and trash-enabled delete let basic_file_router = Router::new() - .route("/", get(| - State(state): State, - axum::extract::Query(params): axum::extract::Query>, - | async move { - // Get folder_id from query parameter if present - let folder_id = params.get("folder_id").map(|id| id.as_str()); - tracing::info!("API: Listando archivos con folder_id: {:?}", folder_id); - let service = &state.applications.file_service_concrete; - match service.list_files(folder_id).await { - Ok(files) => { - tracing::info!("Found {} files", files.len()); - (StatusCode::OK, Json(files)).into_response() - }, - Err(err) => { - tracing::error!("Error listing files: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error listing files: {}", err) - }))).into_response() - } - } - })) - .route("/upload", post(| - State(state): State, - multipart: axum::extract::Multipart, - | async move { - use crate::infrastructure::services::thumbnail_service::ThumbnailService; - - // Use the new upload handler with write-behind cache - let response = FileHandler::upload_file_with_cache( - State(state.clone()), - multipart - ).await; - - // Try to extract file info for thumbnail generation - if let Ok(body_bytes) = axum::body::to_bytes(response.into_response().into_body(), 10 * 1024).await { - if let Ok(file_info) = serde_json::from_slice::(&body_bytes) { - if let (Some(file_id), Some(mime_type), Some(file_path_str)) = ( - file_info.get("id").and_then(|v| v.as_str()), - file_info.get("mime_type").and_then(|v| v.as_str()), - file_info.get("path").and_then(|v| v.as_str()) - ) { - // Generate thumbnails for images in background - if ThumbnailService::is_supported_image(mime_type) { - let file_id = file_id.to_string(); - let file_path_rel = file_path_str.to_string(); - let thumbnail_service = state.core.thumbnail_service.clone(); - let path_service = state.core.path_service.clone(); - - tokio::spawn(async move { - let file_path = path_service.get_root_path().join(&file_path_rel); - tracing::info!("🖼️ Generating thumbnails for: {}", file_id); - thumbnail_service.generate_all_sizes_background(file_id, file_path); - }); - } - - // Return the response - return axum::http::Response::builder() - .status(axum::http::StatusCode::CREATED) - .header(axum::http::header::CONTENT_TYPE, "application/json") - .header(axum::http::header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(axum::body::Body::from(body_bytes)) - .unwrap() - .into_response(); - } - } - } - - // Fallback for errors - (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Upload processing error").into_response() - })) + .route("/", get(FileHandler::list_files_query)) + .route("/upload", post(FileHandler::upload_file_with_thumbnails)) .route("/{id}", get(FileHandler::download_file)) .route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail)) .with_state(app_state.clone()); - // Let's create a router for file operations with trash support + // File operations with trash support let file_operations_router = Router::new() - // CRITICAL FIX: Ensure file deletion route correctly calls FileHandler::delete_file - // Uses the correct URL pattern - .route("/{id}", delete(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path - | async move { - tracing::info!("File delete route called explicitly for ID: {}", id); - FileHandler::delete_file(State(state), CurrentUserId(user_id), Path(id)).await - })) - .route("/{id}/move", put(| - State(state): State, - Path(id): Path, - Json(payload): Json, - | async move { - // Simplified move implementation just to get it working - let folder_id = payload.get("folder_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let file_service = &state.applications.file_service; - match file_service.move_file(&id, folder_id).await { - Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - })); + .route("/{id}", delete(FileHandler::delete_file)) + .route("/{id}/move", put(FileHandler::move_file_simple)); // Merge the routers let files_router = basic_file_router.merge(file_operations_router); @@ -418,7 +170,7 @@ pub fn create_api_routes( // Implementaciones directas de handlers para compartir, sin depender de ShareHandler - // Create routes for shared resources if the service is available + // Create routes for shared resources management (requires auth) let share_router = if let Some(share_service) = share_service.clone() { use crate::interfaces::api::handlers::share_handler; @@ -432,18 +184,6 @@ pub fn create_api_routes( } else { Router::new() }; - - // Public route for accessing shared links - let public_share_router = if let Some(share_service) = share_service.clone() { - use crate::interfaces::api::handlers::share_handler; - - Router::new() - .route("/{token}", get(share_handler::access_shared_item)) - .route("/{token}/verify", post(share_handler::verify_shared_item_password)) - .with_state(share_service.clone()) - } else { - Router::new() - }; // Create a router without the i18n routes // Create routes for favorites if the service is available @@ -500,236 +240,21 @@ pub fn create_api_routes( .nest("/batch", batch_router) .nest("/search", search_router) .nest("/shares", share_router) - .nest("/s", public_share_router) .nest("/favorites", favorites_router) .nest("/recent", recent_router) ; - - // Store the share service in app_state for future use - if let Some(share_service) = share_service.clone() { - app_state.share_service = Some(share_service); - } // Re-enable trash routes to make the trash view work if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view"); - // Create a router for trash specific endpoints that handles the auth requirements - // Implement all trash operations needed by the frontend let trash_router = Router::new() - // Get all trash items - .route("/", get(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Query(params): Query> - | async move { - tracing::info!("Getting trash items"); - // Use a valid UUID for the default user or from query params - let effective_user = params.get("userId") - .cloned() - .unwrap_or(user_id); - - tracing::info!("Using user ID: {}", effective_user); - // Get the trash service directly - if let Some(trash_service) = &state.trash_service { - // Get trash items for default user - match trash_service.get_trash_items(&effective_user).await { - Ok(items) => { - tracing::info!("Found {} items in trash", items.len()); - let response_data = serde_json::json!(items); - tracing::info!("Response data: {:?}", response_data); - (StatusCode::OK, Json(response_data)).into_response() - }, - Err(err) => { - tracing::error!("Error getting trash items: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error getting trash items: {}", err) - }))).into_response() - } - } - } else { - tracing::error!("Trash service not available"); - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))).into_response() - } - })) - // Move file to trash - .route("/files/{id}", delete(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path - | async move { - tracing::info!("Moving file to trash: {}", id); - - if let Some(trash_service) = &state.trash_service { - match trash_service.move_to_trash(&id, "file", &user_id).await { - Ok(_) => { - tracing::info!("File moved to trash successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "File moved to trash successfully" - }))).into_response() - }, - Err(err) => { - tracing::error!("Error moving file to trash: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error moving file to trash: {}", err) - }))).into_response() - } - } - } else { - tracing::error!("Trash service not available"); - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))).into_response() - } - })) - // Move folder to trash - .route("/folders/{id}", delete(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path - | async move { - tracing::info!("Moving folder to trash: {}", id); - - if let Some(trash_service) = &state.trash_service { - match trash_service.move_to_trash(&id, "folder", &user_id).await { - Ok(_) => { - tracing::info!("Folder moved to trash successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Folder moved to trash successfully" - }))).into_response() - }, - Err(err) => { - tracing::error!("Error moving folder to trash: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error moving folder to trash: {}", err) - }))).into_response() - } - } - } else { - tracing::error!("Trash service not available"); - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))).into_response() - } - })) - // Restore item from trash - .route("/{id}/restore", post(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path - | async move { - tracing::info!("Restoring item from trash: {}", id); - - if let Some(trash_service) = &state.trash_service { - match trash_service.restore_item(&id, &user_id).await { - Ok(_) => { - tracing::info!("Item restored from trash successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item restored from trash successfully" - }))).into_response() - }, - Err(err) => { - let err_str = format!("{}", err); - // Check if the error is due to item not being found - if err_str.contains("not found") || err_str.contains("NotFound") { - tracing::warn!("Item not found in trash, but reporting success: {}", id); - // Return success even if the item is not found - return (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item restored (or was already removed from trash)" - }))).into_response(); - } - - tracing::error!("Error restoring item from trash: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error restoring item from trash: {}", err) - }))).into_response() - } - } - } else { - tracing::error!("Trash service not available"); - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))).into_response() - } - })) - // Permanently delete an item from trash - .route("/{id}", delete(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - Path(id): Path - | async move { - tracing::info!("Permanently deleting item from trash: {}", id); - - if let Some(trash_service) = &state.trash_service { - match trash_service.delete_permanently(&id, &user_id).await { - Ok(_) => { - tracing::info!("Item permanently deleted successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item permanently deleted" - }))).into_response() - }, - Err(err) => { - let err_str = format!("{}", err); - // Check if the error is due to item not being found - if err_str.contains("not found") || err_str.contains("NotFound") { - tracing::warn!("Item not found in trash, but reporting success: {}", id); - // Return success even if the item is not found - return (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item deleted (or was already removed from trash)" - }))).into_response(); - } - - tracing::error!("Error permanently deleting item: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error permanently deleting item: {}", err) - }))).into_response() - } - } - } else { - tracing::error!("Trash service not available"); - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))).into_response() - } - })) - // Empty trash - .route("/empty", delete(| - State(state): State, - CurrentUserId(user_id): CurrentUserId, - | async move { - tracing::info!("Emptying trash"); - - if let Some(trash_service) = &state.trash_service { - match trash_service.empty_trash(&user_id).await { - Ok(_) => { - tracing::info!("Trash emptied successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Trash emptied successfully" - }))).into_response() - }, - Err(err) => { - tracing::error!("Error emptying trash: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error emptying trash: {}", err) - }))).into_response() - } - } - } else { - tracing::error!("Trash service not available"); - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))).into_response() - } - })) + .route("/", get(trash_handler::get_trash_items)) + .route("/files/{id}", delete(trash_handler::move_file_to_trash)) + .route("/folders/{id}", delete(trash_handler::move_folder_to_trash)) + .route("/{id}/restore", post(trash_handler::restore_from_trash)) + .route("/{id}", delete(trash_handler::delete_permanently)) + .route("/empty", delete(trash_handler::empty_trash)) .with_state(app_state.clone()); router = router.nest("/trash", trash_router); @@ -737,66 +262,19 @@ pub fn create_api_routes( tracing::warn!("Trash service not available - trash view will not work"); } - // Add i18n routes if the service is provided - if let Some(i18n_service) = i18n_service { - let i18n_router = Router::new() - .route("/locales", get(I18nHandler::get_locales)) - .route("/translate", get(I18nHandler::translate)) - .route("/locales/{locale_code}", get(| - State(service): State>, - axum::extract::Path(locale_code): axum::extract::Path, - | async move { - I18nHandler::get_translations(State(service), locale_code).await - })) - .with_state(i18n_service); - - router = router.nest("/i18n", i18n_router); - } - - // Get the app configuration - let _config = AppConfig::from_env(); - - // For now, just use the router as is - we'll properly implement the auth middleware later - // when all implementation details are fixed - let router = router; - - // Apply compression and tracing layers - // Note: We've removed the direct trash endpoints due to handler type compatibility issues - // These will need to be implemented directly in main.rs or by modifying the file/folder handlers - if trash_service.is_some() { - tracing::info!("Trash service is available - trash view is functional"); - } - - // Add WebDAV routes if needed - let webdav_enabled = true; // In production, you'd read this from a config - let router = if webdav_enabled { + // Add WebDAV routes + { use crate::interfaces::api::handlers::webdav_handler; - router.merge(webdav_handler::webdav_routes()) - } else { - router - }; + router = router.merge(webdav_handler::webdav_routes()); + } - // Add CalDAV routes if needed - let caldav_enabled = true; // In production, you'd read this from a config - let router = if caldav_enabled { + // Add CalDAV routes + { use crate::interfaces::api::handlers::caldav_handler; - router.nest("/caldav", caldav_handler::caldav_routes()) - } else { - router - }; - - // Add CardDAV routes if needed - let carddav_enabled = true; // In production, you'd read this from a config - let router = if carddav_enabled { - // Note: We'll implement carddav_handler in the next phase - router - } else { - router - }; + router = router.nest("/caldav", caldav_handler::caldav_routes()); + } router .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) - // HTTP caching is disabled temporarily due to compatibility issues - // .layer(HttpCacheLayer::new(http_cache.clone()).with_max_age(folders_ttl)) } \ No newline at end of file diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 8054f78b..fb7ba601 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -22,7 +22,7 @@ pub struct AuthUser { /// Se extrae automáticamente del `CurrentUser` insertado por el auth middleware. /// /// Uso en handlers: -/// ```rust +/// ```ignore /// async fn my_handler(CurrentUserId(user_id): CurrentUserId) -> impl IntoResponse { ... } /// ``` #[derive(Clone, Debug)] @@ -80,6 +80,9 @@ pub enum AuthError { #[error("Acceso denegado: {0}")] AccessDenied(String), + + #[error("Servicio de autenticación no disponible")] + AuthServiceUnavailable, } impl IntoResponse for AuthError { @@ -90,6 +93,7 @@ impl IntoResponse for AuthError { AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expirado".to_string()), AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()), AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg), + AuthError::AuthServiceUnavailable => (StatusCode::INTERNAL_SERVER_ERROR, "Servicio de autenticación no disponible".to_string()), }; let body = axum::Json(serde_json::json!({ @@ -100,146 +104,75 @@ impl IntoResponse for AuthError { } } -// Middleware de autenticación simplificado - solo valida si existe un token +/// Middleware de autenticación seguro. +/// +/// Valida el token JWT contra el servicio de autenticación configurado. +/// No acepta bypasses, tokens mock, ni parámetros de URL para saltar validación. pub async fn auth_middleware( State(state): State>, headers: HeaderMap, mut request: Request, next: Next, ) -> Result { - // Check URL for special no_validation parameter to break auth loops - let uri = request.uri().to_string(); - let skip_validation = uri.contains("no_redirect=true") || uri.contains("bypass_auth=true"); - - if skip_validation { - tracing::info!("Bypassing token validation due to special URL parameter"); - // Create a default user for the request - let current_user = CurrentUser { - id: "default-user-id".to_string(), - username: "usuario".to_string(), - email: "usuario@example.com".to_string(), - role: "user".to_string(), - }; - request.extensions_mut().insert(current_user); - return Ok(next.run(request).await); - } - - // En una primera etapa, simplemente verificar si hay un token, sin validarlo - if let Some(token_str) = headers + // Extraer el token Bearer del header Authorization + let token_str = headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) { - - // Handle mock tokens differently - let is_mock = token_str.contains("mock") || token_str == "mock_access_token"; - - if is_mock { - tracing::info!("Mock token detected, using simplified validation"); - let current_user = CurrentUser { - id: "test-user-id".to_string(), - username: "test".to_string(), - email: "test@example.com".to_string(), - role: "user".to_string(), - }; - request.extensions_mut().insert(current_user); - return Ok(next.run(request).await); - } - - // Process normal token - try to validate it using JWT service - tracing::info!("Processing token: {}", token_str.chars().take(8).collect::() + "..."); - - // Try to get the token service and validate the token - if let Some(auth_service) = state.auth_service.as_ref() { - let token_service = &auth_service.token_service; - match token_service.validate_token(token_str) { - Ok(claims) => { - tracing::info!("Token validated successfully for user: {}", claims.username); - let current_user = CurrentUser { - id: claims.sub, - username: claims.username, - email: claims.email, - role: claims.role, - }; - request.extensions_mut().insert(current_user); - return Ok(next.run(request).await); - }, - Err(e) => { - tracing::warn!("Token validation failed: {}", e); - return Err(AuthError::InvalidToken(format!("Token inválido: {}", e))); - } - } - } - - // Fallback: if no auth service available, use token claims from parsing JWT manually - // Try to decode the token manually using jsonwebtoken - use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; - - // Try with default secret (from environment or config) - let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "oxicloud_secret_key_please_change_in_production".to_string()); - - #[derive(serde::Deserialize)] - struct Claims { - sub: String, - username: String, - email: String, - role: String, - } - - let validation = Validation::new(Algorithm::HS256); - match decode::( - token_str, - &DecodingKey::from_secret(jwt_secret.as_bytes()), - &validation - ) { - Ok(token_data) => { - tracing::info!("Token decoded successfully for user: {}", token_data.claims.username); + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or(AuthError::TokenNotProvided)?; + + // Validar que el token no esté vacío + let token_str = token_str.trim(); + if token_str.is_empty() { + return Err(AuthError::TokenNotProvided); + } + + tracing::debug!("Processing authentication token"); + + // Validar el token usando el servicio de autenticación + if let Some(auth_service) = state.auth_service.as_ref() { + let token_service = &auth_service.token_service; + match token_service.validate_token(token_str) { + Ok(claims) => { + tracing::debug!("Token validated successfully for user: {}", claims.username); let current_user = CurrentUser { - id: token_data.claims.sub, - username: token_data.claims.username, - email: token_data.claims.email, - role: token_data.claims.role, + id: claims.sub, + username: claims.username, + email: claims.email, + role: claims.role, }; request.extensions_mut().insert(current_user); return Ok(next.run(request).await); }, Err(e) => { - tracing::warn!("Fallback token decode failed: {}", e); + tracing::warn!("Token validation failed: {}", e); return Err(AuthError::InvalidToken(format!("Token inválido: {}", e))); } } } - // Si hay un indicador para evitar redirección, permitir el acceso sin token - if uri.contains("api/") && uri.contains("login") { - tracing::info!("Allowing access to login endpoint without token"); - return Ok(next.run(request).await); - } - - // Si no hay token, devolver error de token no proporcionado - Err(AuthError::TokenNotProvided) + // Si no hay servicio de autenticación disponible, denegar acceso + tracing::error!("Auth middleware invoked but auth service is not configured"); + Err(AuthError::AuthServiceUnavailable) } -// Middleware simplificado para verificar roles de administrador +/// Middleware para verificar que el usuario autenticado tiene rol de administrador. +/// +/// Debe aplicarse DESPUÉS del auth_middleware, ya que depende de que +/// `CurrentUser` esté presente en las extensiones de la request. pub async fn require_admin( - headers: HeaderMap, - mut request: Request, + request: Request, next: Next, ) -> Response { - // Implementación simplificada que verifica si hay un token de admin - if let Some(auth_value) = headers.get(header::AUTHORIZATION) { - if let Ok(auth_str) = auth_value.to_str() { - if auth_str.contains("admin") { - // Autorizado como admin - let current_user = CurrentUser { - id: "admin-user-id".to_string(), - username: "admin".to_string(), - email: "admin@example.com".to_string(), - role: "admin".to_string(), - }; - request.extensions_mut().insert(current_user); - return next.run(request).await; - } + // Obtener el CurrentUser insertado por auth_middleware + if let Some(current_user) = request.extensions().get::() { + if current_user.role == "admin" { + tracing::debug!("Admin access granted for user: {}", current_user.username); + return next.run(request).await; } + tracing::warn!("Admin access denied for user: {} (role: {})", current_user.username, current_user.role); + } else { + tracing::warn!("Admin check failed: no authenticated user in request"); } // Acceso denegado diff --git a/src/interfaces/middleware/cache.rs b/src/interfaces/middleware/cache.rs index e1448dff..f22ac858 100644 --- a/src/interfaces/middleware/cache.rs +++ b/src/interfaces/middleware/cache.rs @@ -373,7 +373,7 @@ where let future = self.inner.call(req); return Box::pin(async move { let response = future.await.map_err(|e| e.into())?; - Ok(response_map_body(response)) + Ok(response_map_body(response).await) }); } @@ -421,7 +421,7 @@ where return Box::pin(async move { let response = future.await.map_err(|e| e.into())?; - let response = response_map_body(response); + let response = response_map_body(response).await; // No cachear errores if !response.status().is_success() { @@ -455,19 +455,27 @@ where } } -// Función auxiliar para convertir cualquier cuerpo en Body -fn response_map_body(response: Response) -> Response +// Función auxiliar para convertir cualquier cuerpo en Body preservando su contenido. +// Anteriormente esta función descartaba el body con Body::empty(), causando +// pérdida de datos en respuestas no cacheadas. +async fn response_map_body(response: Response) -> Response where B: http_body::Body + Send + 'static, B::Data: Send + 'static, B::Error: Into>, { - let (parts, _body) = response.into_parts(); - - // Create a simple empty body as a fallback - in production you would handle this better - let mapped_body = Body::empty(); - - Response::from_parts(parts, mapped_body) + use http_body_util::BodyExt; + + let (parts, body) = response.into_parts(); + + // Collect the full body into Bytes, preserving all response data + let collected = body + .collect() + .await + .map(|c| c.to_bytes()) + .unwrap_or_default(); + + Response::from_parts(parts, Body::from(collected)) } /// Inicia una tarea de limpieza periódica para el caché @@ -488,14 +496,9 @@ pub fn start_cache_cleanup_task(cache: HttpCache) { #[cfg(test)] mod tests { use super::*; - use hyper::{Request, Body, Response}; - use axum::routing::get; - use axum::{Extension, Json, Router}; - use tower::ServiceExt; - use http::StatusCode; use serde::{Deserialize, Serialize}; - #[derive(Debug, Serialize, Deserialize)] + #[derive(Debug, Serialize, Deserialize, Hash)] struct TestData { id: u32, name: String, @@ -505,13 +508,13 @@ mod tests { async fn test_etag_generation() { let cache = HttpCache::new(); - let data1 = TestData { id: 1, name: "Test".to_string() }; - let data2 = TestData { id: 1, name: "Test".to_string() }; - let data3 = TestData { id: 2, name: "Test".to_string() }; + let data1 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap(); + let data2 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap(); + let data3 = serde_json::to_vec(&TestData { id: 2, name: "Test".to_string() }).unwrap(); - let etag1 = cache.calculate_etag(&data1); - let etag2 = cache.calculate_etag(&data2); - let etag3 = cache.calculate_etag(&data3); + let etag1 = cache.calculate_etag_for_bytes(&data1); + let etag2 = cache.calculate_etag_for_bytes(&data2); + let etag3 = cache.calculate_etag_for_bytes(&data3); // Mismos datos deben generar mismo ETag assert_eq!(etag1, etag2); @@ -524,17 +527,12 @@ mod tests { async fn test_cache_hit_miss() { let cache = HttpCache::new(); - // Primera petición (cache miss) - let response1 = Response::builder() - .status(StatusCode::OK) - .body(Body::from(r#"{"id":1,"name":"Test"}"#)) - .unwrap(); - - let (parts1, body1) = response1.into_parts(); - let bytes1 = hyper::body::to_bytes(body1).await.unwrap(); + // Crear datos de prueba directamente como Bytes + let bytes1 = Bytes::from(r#"{"id":1,"name":"Test"}"#); + let headers1 = HeaderMap::new(); let etag1 = cache.calculate_etag_for_bytes(&bytes1); - cache.set("test", etag1.clone(), Some(bytes1.clone()), parts1.headers.clone(), None); + cache.set("test", etag1.clone(), Some(bytes1.clone()), headers1, None); // Verificar cache hit let entry = cache.get("test").unwrap(); diff --git a/src/interfaces/mod.rs b/src/interfaces/mod.rs index e91f832f..83e6e6d0 100644 --- a/src/interfaces/mod.rs +++ b/src/interfaces/mod.rs @@ -4,3 +4,4 @@ pub mod middleware; pub mod errors; pub use api::create_api_routes; +pub use api::create_public_api_routes; diff --git a/src/lib.rs b/src/lib.rs index 9b78d543..8fb338fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,13 +7,12 @@ pub mod interfaces; // Re-exportaciones públicas comunes pub use application::services::folder_service::FolderService; -pub use application::services::file_service::FileService; pub use application::services::i18n_application_service::I18nApplicationService; pub use application::services::storage_mediator::{StorageMediator, FileSystemStorageMediator}; pub use infrastructure::services::path_service::PathService; pub use domain::services::path_service::StoragePath; pub use infrastructure::repositories::folder_fs_repository::FolderFsRepository; -pub use infrastructure::repositories::file_fs_repository::FileFsRepository; +pub use infrastructure::repositories::CompositeFileRepository; pub use infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; pub use infrastructure::services::buffer_pool::BufferPool; pub use infrastructure::services::compression_service::GzipCompressionService; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 33eb5530..728db4a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,37 +30,13 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; /// /// @author OxiCloud Development Team -// Use the library crate instead of redefining modules -use oxicloud::application; use oxicloud::common; -use oxicloud::domain; use oxicloud::infrastructure; use oxicloud::interfaces; -use application::services::folder_service::FolderService; -use application::services::file_service::FileService; -use application::services::i18n_application_service::I18nApplicationService; -use application::services::storage_mediator::FileSystemStorageMediator; -use application::services::share_service::ShareService; -use application::services::favorites_service::FavoritesService; -use infrastructure::services::path_service::PathService; -use infrastructure::repositories::folder_fs_repository::FolderFsRepository; -use infrastructure::repositories::file_fs_repository::FileFsRepository; -use infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; -use infrastructure::repositories::share_fs_repository::ShareFsRepository; -use infrastructure::services::file_system_i18n_service::FileSystemI18nService; -use infrastructure::services::id_mapping_service::IdMappingService; -use infrastructure::services::id_mapping_optimizer::IdMappingOptimizer; -use infrastructure::services::file_metadata_cache::FileMetadataCache; -use infrastructure::services::buffer_pool::BufferPool; -use infrastructure::services::compression_service::GzipCompressionService; -use interfaces::{create_api_routes, web::create_web_routes}; -use application::services::trash_service::TrashService; -use infrastructure::repositories::trash_fs_repository::TrashFsRepository; -use infrastructure::services::trash_cleanup_service::TrashCleanupService; -use common::db::create_database_pool; -use common::auth_factory::create_auth_services; -use common::di::AppState; +use common::di::AppServiceFactory; +use infrastructure::db::create_database_pool; +use interfaces::{create_api_routes, create_public_api_routes, web::create_web_routes}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -75,25 +51,23 @@ async fn main() -> Result<(), Box> { // Load configuration from environment variables let config = common::config::AppConfig::from_env(); - // Set up storage directory + // Ensure storage and locales directories exist let storage_path = config.storage_path.clone(); if !storage_path.exists() { std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory"); } - - // Set up locales directory let locales_path = PathBuf::from("./static/locales"); if !locales_path.exists() { std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory"); } - // Initialize database if auth is enabled + // Initialize database pool if auth is enabled let db_pool = if config.features.enable_auth { match create_database_pool(&config).await { Ok(pool) => { tracing::info!("PostgreSQL database pool initialized successfully"); Some(Arc::new(pool)) - }, + } Err(e) => { tracing::error!("Failed to initialize database pool: {}", e); None @@ -103,777 +77,69 @@ async fn main() -> Result<(), Box> { None }; - // Create a reference to db_pool for use throughout the code - let db_pool_ref = db_pool.as_ref(); - - // Initialize path service - let path_service = Arc::new(PathService::new(storage_path.clone())); - - // Initialize ID mapping service for folders - let folder_id_mapping_path = storage_path.join("folder_ids.json"); - let folder_id_mapping_service = Arc::new( - IdMappingService::new(folder_id_mapping_path).await - .expect("Failed to initialize folder ID mapping service") - ); - - // Initialize ID mapping service for files - let file_id_mapping_path = storage_path.join("file_ids.json"); - let file_id_mapping_service = Arc::new( - IdMappingService::new(file_id_mapping_path).await - .expect("Failed to initialize file ID mapping service") - ); - - // For backward compatibility, use folder ID service as the base ID mapping service - let base_id_mapping_service = folder_id_mapping_service.clone(); - - // Create optimized ID mapping service with batch processing and caching - let id_mapping_optimizer = Arc::new( - IdMappingOptimizer::new(base_id_mapping_service.clone()) - ); - - // Initialize folder repository with all required components - let folder_repository = Arc::new(FolderFsRepository::new( - storage_path.clone(), - Arc::new(FileSystemStorageMediator::new_stub()), // Temporary stub (will be replaced) - base_id_mapping_service.clone(), - path_service.clone() - )); - - // Initialize storage mediator - let storage_mediator = Arc::new(FileSystemStorageMediator::new( - folder_repository.clone(), - path_service.clone(), - id_mapping_optimizer.clone() - )); - - // Update folder repository with proper storage mediator - // This replaces the stub we initialized it with - let folder_repository = Arc::new(FolderFsRepository::new( - storage_path.clone(), - storage_mediator.clone(), - base_id_mapping_service.clone(), - path_service.clone() - )); - - // Start cleanup task for ID mapping optimizer - IdMappingOptimizer::start_cleanup_task(id_mapping_optimizer.clone()); - - tracing::info!("ID mapping optimizer initialized with batch processing and caching"); - - // Initialize the metadata cache - let config = common::config::AppConfig::default(); - let metadata_cache = Arc::new(FileMetadataCache::default_with_config(config.clone())); - - // Start the periodic cleanup task for cache maintenance - let cache_clone = metadata_cache.clone(); - tokio::spawn(async move { - FileMetadataCache::start_cleanup_task(cache_clone).await; - }); - - // Initialize the buffer pool for memory optimization - // Use larger buffer size for better performance with large files - let buffer_pool = BufferPool::new(256 * 1024, 50, 120); // 256KB buffers, 50 max, 2 min TTL - - // Start the buffer pool cleanup task - BufferPool::start_cleaner(buffer_pool.clone()); - - tracing::info!("Buffer pool initialized with 50 buffers of 256KB each"); - - // Initialize parallel file processor with buffer pool - let parallel_processor = Arc::new(ParallelFileProcessor::new_with_buffer_pool( + // Build all services via the factory + let factory = AppServiceFactory::with_config( + storage_path, + locales_path, config.clone(), - buffer_pool.clone() - )); - - // Initialize compression service with buffer pool - let _compression_service = Arc::new(GzipCompressionService::new_with_buffer_pool( - buffer_pool.clone() - )); - - // Initialize file repository with mediator, ID mapping service, metadata cache, and parallel processor - let file_repository = Arc::new(FileFsRepository::new_with_processor( - storage_path.clone(), - storage_mediator, - file_id_mapping_service.clone(), // Use the file-specific ID mapping service - path_service.clone(), - metadata_cache.clone(), // Clone to keep a reference for later use - parallel_processor - )); - - // Initialize application services - let folder_service = Arc::new(FolderService::new(folder_repository.clone())); - let file_service = Arc::new(FileService::new(file_repository.clone())); - - // Initialize trash service if enabled - let trash_repository = if config.features.enable_trash { - Some(Arc::new(TrashFsRepository::new( - storage_path.as_path(), - base_id_mapping_service.clone(), - ))) - } else { - None - }; - - // Create adapters for repositories (using domain interfaces instead of ports) - struct DomainFileRepoAdapter { - repo: Arc - } - - impl DomainFileRepoAdapter { - fn new(repo: Arc) -> Self { - Self { repo } - } - } - - #[async_trait::async_trait] - impl domain::repositories::file_repository::FileRepository for DomainFileRepoAdapter { - async fn save_file_from_bytes( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> domain::repositories::file_repository::FileRepositoryResult { - self.repo.save_file(name, folder_id, content_type, content) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn save_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - stream: std::pin::Pin> + Send>>, - ) -> domain::repositories::file_repository::FileRepositoryResult { - self.repo.save_file_from_stream(name, folder_id, content_type, stream) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn save_file_with_id( - &self, - _id: String, - _name: String, - _folder_id: Option, - _content_type: String, - _content: Vec, - ) -> domain::repositories::file_repository::FileRepositoryResult { - Err(domain::repositories::file_repository::FileRepositoryError::Other("Not implemented".to_string())) - } - - async fn get_file_by_id(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult { - self.repo.get_file(id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn list_files(&self, folder_id: Option<&str>) -> domain::repositories::file_repository::FileRepositoryResult> { - self.repo.list_files(folder_id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn delete_file(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult<()> { - self.repo.delete_file(id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn delete_file_entry(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult<()> { - self.delete_file(id).await - } - - async fn get_file_content(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult> { - self.repo.get_file_content(id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_stream(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult> + Send>> { - self.repo.get_file_stream(id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option, - ) -> domain::repositories::file_repository::FileRepositoryResult> + Send>> { - self.repo.get_file_range_stream(id, start, end) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_mmap(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult { - self.repo.get_file_mmap(id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn move_file(&self, id: &str, target_folder_id: Option) -> domain::repositories::file_repository::FileRepositoryResult { - self.repo.move_file(id, target_folder_id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn get_file_path(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult { - self.repo.get_file_path(id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn move_to_trash(&self, file_id: &str) -> domain::repositories::file_repository::FileRepositoryResult<()> { - // Since we're using TrashService to handle trashing, this method is not directly used - // but we'll implement it by delegating to the repository's delete_file method - self.repo.delete_file(file_id) - .await - .map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e))) - } - - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> domain::repositories::file_repository::FileRepositoryResult<()> { - - - tracing::info!("Restoring file from trash: {} to {}", file_id, original_path); - - // We need to get the file from trash first to ensure it exists - match self.repo.get_file(file_id).await { - Ok(_) => { - // Extract the parent folder ID from the original path if available - let path_components: Vec<&str> = original_path.split('/').collect(); - let parent_folder: Option = if path_components.len() > 1 { - // Try to extract folder ID from path, but this is just a simplified approach - // In a real implementation, we would need to find or create the folder - tracing::info!("Attempting to restore to parent folder from path: {}", original_path); - None // No folder ID for now, will go to root - } else { - None // No parent folder, go to root - }; - - // Use move_file to attempt to restore the file to its original location or root - match self.repo.move_file(file_id, parent_folder).await { - Ok(_) => { - tracing::info!("Successfully restored file from trash: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to restore file from trash: {}", e); - Err(domain::repositories::file_repository::FileRepositoryError::Other(format!("Failed to restore file: {}", e))) - } - } - }, - Err(e) => { - tracing::error!("File not found in trash: {}", e); - Err(domain::repositories::file_repository::FileRepositoryError::NotFound(file_id.to_string())) - } - } - } - - async fn delete_file_permanently(&self, file_id: &str) -> domain::repositories::file_repository::FileRepositoryResult<()> { - tracing::info!("Permanently deleting file: {}", file_id); - - // Directly attempt to delete the file using the file service - match self.repo.delete_file(file_id).await { - Ok(_) => { - tracing::info!("Successfully deleted file permanently: {}", file_id); - Ok(()) - }, - Err(e) => { - tracing::error!("Failed to permanently delete file: {}", e); - Err(domain::repositories::file_repository::FileRepositoryError::Other(format!("Failed to delete file permanently: {}", e))) - } - } - } - - async fn update_file_content(&self, file_id: &str, content: Vec) -> domain::repositories::file_repository::FileRepositoryResult<()> { - tracing::info!("Updating content for file: {}", file_id); - - self.repo.update_file_content(file_id, content) - .await - .map_err(|e| { - tracing::error!("Failed to update file content: {}", e); - domain::repositories::file_repository::FileRepositoryError::Other(format!("Failed to update file content: {}", e)) - }) - } - } - - struct DomainFolderRepoAdapter { - repo: Arc - } - - impl DomainFolderRepoAdapter { - fn new(repo: Arc) -> Self { - Self { repo } - } - } - - #[async_trait::async_trait] - impl domain::repositories::folder_repository::FolderRepository for DomainFolderRepoAdapter { - async fn create_folder(&self, name: String, parent_id: Option) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.create_folder(name, parent_id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn get_folder_by_id(&self, id: &str) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.get_folder(id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn get_folder_by_storage_path(&self, storage_path: &domain::services::path_service::StoragePath) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.get_folder_by_path(storage_path) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn list_folders(&self, parent_id: Option<&str>) -> domain::repositories::folder_repository::FolderRepositoryResult> { - self.repo.list_folders(parent_id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - offset: usize, - limit: usize, - include_total: bool - ) -> domain::repositories::folder_repository::FolderRepositoryResult<(Vec, Option)> { - self.repo.list_folders_paginated(parent_id, offset, limit, include_total) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn rename_folder(&self, id: &str, new_name: String) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.rename_folder(id, new_name) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.move_folder(id, new_parent_id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn delete_folder(&self, id: &str) -> domain::repositories::folder_repository::FolderRepositoryResult<()> { - self.repo.delete_folder(id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn folder_exists_at_storage_path(&self, storage_path: &domain::services::path_service::StoragePath) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.folder_exists(storage_path) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn get_folder_storage_path(&self, id: &str) -> domain::repositories::folder_repository::FolderRepositoryResult { - self.repo.get_folder_path(id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn folder_exists(&self, _path: &std::path::PathBuf) -> domain::repositories::folder_repository::FolderRepositoryResult { - Err(domain::repositories::folder_repository::FolderRepositoryError::Other("Not implemented".to_string())) - } - - async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> domain::repositories::folder_repository::FolderRepositoryResult { - Err(domain::repositories::folder_repository::FolderRepositoryError::Other("Not implemented".to_string())) - } - - async fn move_to_trash(&self, folder_id: &str) -> domain::repositories::folder_repository::FolderRepositoryResult<()> { - // Since we're using TrashService to handle trashing, this method is not directly used - // but we'll still use delete_folder since the underlying repository has proper trash support - self.repo.delete_folder(folder_id) - .await - .map_err(|e| domain::repositories::folder_repository::FolderRepositoryError::Other(format!("{}", e))) - } - - async fn restore_from_trash(&self, _folder_id: &str, original_path: &str) -> domain::repositories::folder_repository::FolderRepositoryResult<()> { - // Convert the original_path to a StoragePath for the repository - use crate::domain::services::path_service::StoragePath; - let storage_path = StoragePath::from_string(original_path); - let _ = storage_path; // Prevent unused variable warning - - // The underlying repo doesn't have a direct API for this, but the implementation exists - // in the folder repository through TrashService - // This should be coordinated through TrashService instead - Err(domain::repositories::folder_repository::FolderRepositoryError::Other( - "Restore from trash should be handled by TrashService, not through this adapter".to_string())) - } - - async fn delete_folder_permanently(&self, folder_id: &str) -> domain::repositories::folder_repository::FolderRepositoryResult<()> { - // The repository now has proper implementation for permanent deletion - // But we still use delete_folder since that's the method available on FolderStoragePort - self.delete_folder(folder_id).await - } - } - - // Create repository adapters - let file_repo_adapter = Arc::new(DomainFileRepoAdapter::new(file_repository.clone())); - let folder_repo_adapter = Arc::new(DomainFolderRepoAdapter::new(folder_repository.clone())); - - // Create the trash service with properly typed adapters - let trash_service = if let Some(ref trash_repo) = trash_repository { - let service = Arc::new(TrashService::new( - trash_repo.clone(), - file_repo_adapter, - folder_repo_adapter, - config.storage.trash_retention_days, - )); - - // Initialize trash cleanup service - let cleanup_service = TrashCleanupService::new( - service.clone(), - trash_repo.clone(), - 24, // Run cleanup every 24 hours - ); - - // Start cleanup job if trash is enabled - if config.features.enable_trash { - cleanup_service.start_cleanup_job().await; - tracing::info!("Trash cleanup service started with daily schedule"); - } - - Some(service as Arc) - } else { - None - }; - - // Initialize i18n service - let i18n_repository = Arc::new(FileSystemI18nService::new(locales_path.clone())); - let i18n_service = Arc::new(I18nApplicationService::new(i18n_repository.clone())); - - // Preload translations - if let Err(e) = i18n_service.load_translations(domain::services::i18n_service::Locale::English).await { - tracing::warn!("Failed to load English translations: {}", e); - } - if let Err(e) = i18n_service.load_translations(domain::services::i18n_service::Locale::Spanish).await { - tracing::warn!("Failed to load Spanish translations: {}", e); - } - - tracing::info!("Compression service initialized with buffer pool support"); - - // Initialize auth services if enabled and database connection is available - let auth_services = if config.features.enable_auth && db_pool_ref.is_some() { - match create_auth_services( - &config, - db_pool_ref.unwrap().clone(), - Some(folder_service.clone()) // Pasar el servicio de carpetas para creación automática de carpetas de usuario - ).await { - Ok(services) => { - tracing::info!("Authentication services initialized successfully with folder service"); - Some(services) - }, - Err(e) => { - tracing::error!("Failed to initialize authentication services: {}", e); - None - } - } - } else { - None - }; - - // Create thumbnail service - let thumbnail_service = Arc::new( - infrastructure::services::thumbnail_service::ThumbnailService::new( - &storage_path, - 5000, // max 5000 thumbnails en cache - 100 * 1024 * 1024, // max 100MB de cache - ) - ); - // Note: thumbnail directories will be initialized when first used - - // Create write-behind cache for zero-latency small file uploads - let write_behind_cache = infrastructure::services::write_behind_cache::WriteBehindCache::new(); - - // Create chunked upload service for large file uploads (>10MB) - let chunked_upload_service = Arc::new( - infrastructure::services::chunked_upload_service::ChunkedUploadService::new( - storage_path.join(".uploads") - ) ); - // Create image transcode service for automatic WebP conversion - let image_transcode_service = Arc::new( - infrastructure::services::image_transcode_service::ImageTranscodeService::new( - &storage_path, - 2000, // max 2000 transcoded images in cache - 50 * 1024 * 1024, // max 50MB memory cache - ) - ); - // Initialize transcode service directories - if let Err(e) = image_transcode_service.initialize().await { - tracing::warn!("Failed to initialize image transcode service: {}", e); - } - - // Create deduplication service for content-addressable storage - let dedup_service = Arc::new( - infrastructure::services::dedup_service::DedupService::new(&storage_path) - ); - if let Err(e) = dedup_service.initialize().await { - tracing::warn!("Failed to initialize dedup service: {}", e); - } - - // Create AppState for DI container - let core_services = common::di::CoreServices { - path_service: path_service.clone(), - cache_manager: Arc::new(infrastructure::services::cache_manager::StorageCacheManager::default()), - file_content_cache: Arc::new(infrastructure::services::file_content_cache::FileContentCache::default()), - id_mapping_service: base_id_mapping_service.clone(), - file_id_mapping_service: file_id_mapping_service.clone(), - id_mapping_optimizer: id_mapping_optimizer.clone(), - thumbnail_service: thumbnail_service.clone(), - write_behind_cache: write_behind_cache.clone(), - chunked_upload_service: chunked_upload_service.clone(), - image_transcode_service: image_transcode_service.clone(), - dedup_service: dedup_service.clone(), - config: config.clone(), - }; - - // Crear stubs para los repositorios - let file_read_stub = Arc::new(infrastructure::repositories::FileFsReadRepository::default_stub()); - let file_write_stub = Arc::new(infrastructure::repositories::FileFsWriteRepository::default_stub()); - let storage_mediator_stub = Arc::new(application::services::storage_mediator::FileSystemStorageMediator::new_stub()); - let metadata_manager = Arc::new(infrastructure::repositories::FileMetadataManager::default()); - let path_resolver_stub = Arc::new(infrastructure::repositories::FilePathResolver::default_stub()); - - let repository_services = common::di::RepositoryServices { - folder_repository: Arc::new(FolderFsRepository::new( - storage_path.clone(), - storage_mediator_stub.clone(), - folder_id_mapping_service.clone(), - path_service.clone() - )), - file_repository: Arc::new(FileFsRepository::new( - storage_path.clone(), - storage_mediator_stub.clone(), - file_id_mapping_service.clone(), - path_service.clone(), - metadata_cache.clone(), - )), - file_read_repository: file_read_stub, - file_write_repository: file_write_stub, - i18n_repository: i18n_repository.clone(), - storage_mediator: storage_mediator_stub, - metadata_manager, - path_resolver: path_resolver_stub, - metadata_cache: metadata_cache.clone(), - trash_repository: trash_repository.clone().map(|repo| { - // Convert Arc to Arc - let repo: Arc = repo; - repo - }), - }; - - // Create the search service - let search_service: Option> = { - // Create the search service with caching - let search_service = Arc::new(application::services::search_service::SearchService::new( - file_repository.clone(), - folder_repository.clone(), - 300, // Cache TTL in seconds (5 minutes) - 1000, // Maximum cache entries - )); - - tracing::info!("Search service initialized with caching (TTL: 300s, max entries: 1000)"); - Some(search_service) - }; - - // Initialize share repository and service if enabled - let share_service: Option> = if config.features.enable_file_sharing { - let share_repository = Arc::new(ShareFsRepository::new( - Arc::new(config.clone()) - )); - - let share_service = Arc::new(ShareService::new( - Arc::new(config.clone()), - share_repository, - file_repository.clone(), - folder_repository.clone() - )); - - tracing::info!("File sharing service initialized successfully"); - Some(share_service) - } else { - tracing::info!("File sharing service is disabled in configuration"); - None - }; - - // Initialize favorites service if database is available - let favorites_service: Option> = - if let Some(pool) = db_pool_ref { - // Create a new favorites service with the database pool - let favorites_service = Arc::new(FavoritesService::new( - pool.clone() - )); - - tracing::info!("Favorites service initialized successfully"); - Some(favorites_service) - } else { - tracing::info!("Favorites service is disabled (requires database connection)"); - None - }; - - // Initialize recent items service if database is available - let recent_service: Option> = - if let Some(pool) = db_pool_ref { - // Create a new service with the database pool - let service = Arc::new(application::services::recent_service::RecentService::new( - pool.clone(), - 50 // Maximum recent items per user - )); - - tracing::info!("Recent items service initialized successfully"); - Some(service) - } else { - tracing::info!("Recent items service is disabled (requires database connection)"); - None - }; - - // For now, we'll use a placeholder for the contact service - // Instead of using the real PostgreSQL repositories, we'll create a dummy implementation - // This makes the code compile, and we can replace it with the real implementation later - let contact_service: Option> = None; - - let application_services = common::di::ApplicationServices { - // Concrete types for backward compatibility with handlers - folder_service_concrete: folder_service.clone(), - file_service_concrete: file_service.clone(), - // Trait objects for abstraction - folder_service: folder_service.clone(), - file_service: file_service.clone(), - file_upload_service: Arc::new(application::services::file_upload_service::FileUploadService::new( - Arc::new(infrastructure::repositories::FileFsWriteRepository::default_stub()) - )), - file_retrieval_service: Arc::new(application::services::file_retrieval_service::FileRetrievalService::new( - Arc::new(infrastructure::repositories::FileFsReadRepository::default_stub()) - )), - file_management_service: Arc::new(application::services::file_management_service::FileManagementService::new( - Arc::new(infrastructure::repositories::FileFsWriteRepository::default_stub()) - )), - file_use_case_factory: Arc::new(application::services::file_use_case_factory::AppFileUseCaseFactory::new( - Arc::new(infrastructure::repositories::FileFsReadRepository::default_stub()), - Arc::new(infrastructure::repositories::FileFsWriteRepository::default_stub()) - )), - i18n_service: i18n_service.clone(), - trash_service: trash_service.clone(), - search_service: search_service.clone(), - share_service: share_service.clone(), - favorites_service: favorites_service.clone(), - recent_service: recent_service.clone() - }; - - // Create the AppState without Arc first - let calendar_service_option = None; - - let mut app_state = AppState { - core: core_services, - repositories: repository_services, - applications: application_services, - db_pool: db_pool.clone(), - auth_service: auth_services.clone(), - trash_service: trash_service.clone(), - share_service: share_service.clone(), - favorites_service: favorites_service.clone(), - recent_service: recent_service.clone(), - storage_usage_service: None, - calendar_service: calendar_service_option, - contact_service: contact_service.clone(), - }; - - // Initialize storage usage service - let _storage_usage_service = if let Some(pool) = db_pool_ref { - // Create a user repository that implements UserStoragePort - let user_repository = Arc::new( - infrastructure::repositories::pg::UserPgRepository::new(pool.clone()) - ); - - // Create storage usage service that uses database for user information - // and file repository for storage calculation - let service = Arc::new(application::services::storage_usage_service::StorageUsageService::new( - file_repository.clone(), - user_repository, - )); - - tracing::info!("Storage usage service initialized successfully"); - - // Add the service to the app state - app_state = app_state.with_storage_usage_service(service.clone()); - - Some(service) - } else { - tracing::info!("Storage usage service is disabled (requires database connection)"); - None - }; - - // Wrap in Arc after all modifications - let app_state = Arc::new(app_state); + let app_state = factory.build_app_state(db_pool).await + .expect("Failed to build application state"); // Build application router - let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service, share_service, favorites_service, recent_service); + let api_routes = create_api_routes(&app_state); + let public_api_routes = create_public_api_routes(&app_state); let web_routes = create_web_routes(); - // Build the app router - // Import auth handler - use interfaces::api::handlers::auth_handler::auth_routes; + let mut app; - // Create basic app router - let mut app = Router::new() - .nest("/api", api_routes) - .merge(web_routes) - .layer(TraceLayer::new_for_http()); - - // Add auth routes if auth is enabled - if config.features.enable_auth && auth_services.is_some() { - // Create auth routes with app state (no middleware needed - handlers extract token directly) - let auth_router = auth_routes().with_state(app_state.clone()); + // Apply auth middleware to protected API routes when auth is enabled + if config.features.enable_auth && app_state.auth_service.is_some() { + use interfaces::api::handlers::auth_handler::auth_routes; + use oxicloud::interfaces::middleware::auth::auth_middleware; - // Add auth routes at /api/auth - app = app.nest("/api/auth", auth_router); + let app_state_arc = Arc::new(app_state.clone()); + let auth_router = auth_routes().with_state(app_state_arc.clone()); + + // Protected API routes — require valid JWT token + let protected_api = api_routes + .layer(axum::middleware::from_fn_with_state(app_state_arc, auth_middleware)); + + app = Router::new() + // Auth endpoints (login, register, refresh) are public — no middleware + .nest("/api/auth", auth_router) + // Public API routes (share access, i18n) — no auth required + .nest("/api", public_api_routes) + // All other API routes are protected by auth middleware + .nest("/api", protected_api) + .merge(web_routes) + .layer(TraceLayer::new_for_http()); + } else { + // Auth disabled — no middleware applied + tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); + app = Router::new() + .nest("/api", public_api_routes) + .nest("/api", api_routes) + .merge(web_routes) + .layer(TraceLayer::new_for_http()); } - // Preload common directories to warm the cache - tracing::info!("Preloading common directories to warm up cache..."); - if let Ok(count) = metadata_cache.preload_directory(&storage_path, true, 1).await { - tracing::info!("Preloaded {} directory entries into cache", count); - } + // Apply the redirect middleware for legacy routes + use oxicloud::interfaces::middleware::redirect::redirect_middleware; + app = app.layer(axum::middleware::from_fn(redirect_middleware)); - // Start server with clear message + // Start server let addr = SocketAddr::from(([0, 0, 0, 0], 8086)); tracing::info!("Starting OxiCloud server on http://{}", addr); - // Start the server - tracing::info!("Authentication system initialized successfully"); - - // Import the redirect middleware - use crate::interfaces::middleware::redirect::redirect_middleware; - - // Apply the redirect middleware to handle legacy routes - app = app.layer(axum::middleware::from_fn(redirect_middleware)); - - // Create a standard TCP listener let listener = tokio::net::TcpListener::bind(addr).await?; - tracing::info!("Server binding to http://{}", addr); - tracing::info!("Starting server with Axum routes..."); - // Axum 0.8 requires the state to match the expected type - // Extract the state from Arc so we can pass it to the router - let app_state_inner = Arc::try_unwrap(app_state) - .unwrap_or_else(|arc| (*arc).clone()); + // Provide the fully-built state to the router + let app = app.with_state(app_state); - // Add global state to the router - let app = app.with_state(app_state_inner); - - // Use axum's serve function with the router with state axum::serve(listener, app).await?; - tracing::info!("Server shutdown completed"); Ok(()) } -