refactoring hexagonal and clean architecture
This commit is contained in:
Generated
+269
-64
@@ -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"
|
||||
|
||||
+6
-6
@@ -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 = []
|
||||
|
||||
@@ -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}")
|
||||
@@ -32,14 +32,14 @@ impl Default for AddressBookDto {
|
||||
impl From<AddressBook> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,26 +112,27 @@ impl Default for ContactDto {
|
||||
|
||||
impl From<Contact> 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<ContactGroup> 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool, DomainError>;
|
||||
|
||||
/// Get content from cache if the file is still pending flush.
|
||||
async fn get_pending(&self, file_id: &str) -> Option<Bytes>;
|
||||
|
||||
/// 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<u64>,
|
||||
pub mime_type: Option<String>,
|
||||
pub created_at: Option<u64>,
|
||||
pub modified_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<CachedMetadataDto>;
|
||||
|
||||
/// Check whether a path is a file (cached). Returns `None` on miss.
|
||||
async fn is_file(&self, path: &Path) -> Option<bool>;
|
||||
|
||||
/// Read actual filesystem metadata and update the cache entry.
|
||||
async fn refresh_metadata(&self, path: &Path) -> Result<CachedMetadataDto, DomainError>;
|
||||
|
||||
/// 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);
|
||||
}
|
||||
@@ -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<usize>,
|
||||
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<String>,
|
||||
content_type: String,
|
||||
total_size: u64,
|
||||
chunk_size: Option<usize>,
|
||||
) -> Result<CreateUploadResponseDto, DomainError>;
|
||||
|
||||
/// 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<String>,
|
||||
) -> Result<ChunkUploadResponseDto, DomainError>;
|
||||
|
||||
/// Get the current status of an upload session.
|
||||
async fn get_status(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<UploadStatusResponseDto, DomainError>;
|
||||
|
||||
/// 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>, 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;
|
||||
}
|
||||
@@ -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<Vec<u8>, DomainError>;
|
||||
|
||||
/// Decompress data in memory.
|
||||
async fn decompress_data(&self, compressed_data: &[u8]) -> Result<Vec<u8>, 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;
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
) -> Result<DedupResultDto, DomainError>;
|
||||
|
||||
/// Store content with deduplication (streaming from file).
|
||||
async fn store_from_file(
|
||||
&self,
|
||||
source_path: &Path,
|
||||
content_type: Option<String>,
|
||||
) -> Result<DedupResultDto, DomainError>;
|
||||
|
||||
/// 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<BlobMetadataDto>;
|
||||
|
||||
/// Read blob content as raw bytes.
|
||||
async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, DomainError>;
|
||||
|
||||
/// Read blob content as `Bytes`.
|
||||
async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError>;
|
||||
|
||||
/// 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<bool, DomainError>;
|
||||
|
||||
/// 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<String, DomainError>;
|
||||
|
||||
/// 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<Vec<String>, DomainError>;
|
||||
}
|
||||
@@ -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<bool>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 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<Vec<FavoriteItemDto>>;
|
||||
|
||||
/// 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<bool>;
|
||||
|
||||
/// Comprueba si un ítem está en favoritos.
|
||||
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
}
|
||||
@@ -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<u8>,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// 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<String>,
|
||||
content_type: String,
|
||||
chunks: Vec<Bytes>,
|
||||
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<FileDto, DomainError>;
|
||||
|
||||
/// 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<FileDto, DomainError>;
|
||||
|
||||
/// Obtiene un archivo por su ruta (para WebDAV)
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Lista archivos en una carpeta
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<bool, DomainError>;
|
||||
}
|
||||
|
||||
/// Factory para crear implementaciones de casos de uso de archivos
|
||||
|
||||
@@ -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<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Obtiene un archivo por su ID
|
||||
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Obtiene un archivo por su ruta (para WebDAV)
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, 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<FileDto, DomainError>;
|
||||
|
||||
/// 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<Vec<FileDto>, 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<Vec<u8>, DomainError>;
|
||||
|
||||
/// Obtiene contenido de archivo como stream (para archivos grandes)
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Mueve un archivo a otra carpeta
|
||||
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError>;
|
||||
}
|
||||
|
||||
/// 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<dyn FileUseCase>;
|
||||
fn create_folder_use_case(&self) -> Arc<dyn FolderUseCase>;
|
||||
fn create_search_use_case(&self) -> Arc<dyn SearchUseCase>;
|
||||
}
|
||||
@@ -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;
|
||||
pub mod thumbnail_ports;
|
||||
pub mod transcode_ports;
|
||||
pub mod trash_ports;
|
||||
pub mod zip_ports;
|
||||
@@ -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<bool, DomainError>;
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// 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<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// Obtiene un archivo por su ID
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError>;
|
||||
|
||||
/// Lista archivos en una carpeta
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, 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<Vec<u8>, DomainError>;
|
||||
|
||||
/// Obtiene contenido de archivo como stream
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Memory-maps archivo para acceso zero-copy (ideal para 10-100MB)
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError>;
|
||||
|
||||
/// Mueve un archivo a otra carpeta
|
||||
async fn move_file(&self, file_id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
|
||||
|
||||
/// Obtiene la ruta de almacenamiento de un archivo
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
/// Obtiene el ID de la carpeta padre para una ruta dada (necesario para WebDAV)
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||
|
||||
/// Actualiza el contenido de un archivo existente
|
||||
async fn update_file_content(&self, file_id: &str, content: Vec<u8>) -> 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<String>,
|
||||
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<String>) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Obtiene una carpeta por su ID
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Obtiene una carpeta por su ruta
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Lista carpetas dentro de una carpeta padre
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, 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<Folder>, Option<usize>), DomainError>;
|
||||
|
||||
/// Renombra una carpeta
|
||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Mueve una carpeta a otro padre
|
||||
async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result<Folder, DomainError>;
|
||||
|
||||
/// 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<bool, DomainError>;
|
||||
|
||||
/// Obtiene la ruta de una carpeta
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
}
|
||||
/// Blanket implementation: cualquier tipo que implemente ambos ports
|
||||
/// es automáticamente un FileStoragePort.
|
||||
impl<T: FileReadPort + FileWritePort> 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<T: FolderRepository> FolderStoragePort for T {}
|
||||
|
||||
/// Puerto secundario para mapeo de IDs
|
||||
#[async_trait]
|
||||
|
||||
@@ -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<Vec<RecentItemDto>>;
|
||||
|
||||
/// 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<bool>;
|
||||
|
||||
/// 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<()>;
|
||||
}
|
||||
@@ -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<File, DomainError>;
|
||||
|
||||
/// Lista archivos en una carpeta
|
||||
|
||||
/// Lista archivos en una carpeta.
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, 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<Vec<u8>, DomainError>;
|
||||
|
||||
/// Obtiene contenido de archivo como stream
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Obtiene contenido como stream (ideal para archivos grandes).
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Memory-map de archivo para acceso zero-copy (10–100 MB).
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError>;
|
||||
|
||||
/// Obtiene la ruta de almacenamiento lógica de un archivo.
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
/// Obtiene el ID de la carpeta padre a partir de una ruta (WebDAV).
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// Mueve un archivo a otra carpeta
|
||||
async fn move_file(&self, file_id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
|
||||
|
||||
/// 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<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// Mueve un archivo a otra carpeta.
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// 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<File, DomainError>;
|
||||
|
||||
/// Obtiene la ruta de una carpeta como string
|
||||
async fn get_folder_path_str(&self, folder_id: &str) -> Result<String, DomainError>;
|
||||
|
||||
/// Actualiza el contenido de un archivo existente.
|
||||
async fn update_file_content(&self, file_id: &str, content: Vec<u8>) -> 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<String>,
|
||||
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<StoragePath, DomainError>;
|
||||
|
||||
|
||||
/// 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<bool, DomainError>;
|
||||
|
||||
|
||||
/// Verifica si existe un directorio en la ruta dada
|
||||
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
}
|
||||
@@ -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<i64, DomainError>;
|
||||
|
||||
|
||||
/// Actualiza estadísticas de uso de almacenamiento para todos los usuarios
|
||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -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<Bytes, DomainError>;
|
||||
|
||||
/// 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<Self>,
|
||||
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;
|
||||
}
|
||||
@@ -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<OutputFormat> {
|
||||
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>;
|
||||
}
|
||||
@@ -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<Vec<u8>, DomainError>;
|
||||
}
|
||||
@@ -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<FileService>,
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
config: AppConfig,
|
||||
semaphore: Arc<Semaphore>,
|
||||
@@ -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<FileService>,
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
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<FileService>,
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>
|
||||
) -> 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<String>, content_type: String, content: Vec<u8>) -> Result<File, DomainError>;
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError>;
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
|
||||
async fn move_file(&self, id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
|
||||
async fn copy_file(&self, id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
mock! {
|
||||
FolderSvcMock {}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderService for FolderSvcMock {
|
||||
async fn create_folder(&self, name: String, parent_id: Option<String>) -> Result<Folder, DomainError>;
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||
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<Vec<Folder>, DomainError>;
|
||||
async fn move_folder(&self, id: &str, target_parent_id: Option<String>) -> Result<Folder, DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
#[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"))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<AddressBookDto, DomainError> {
|
||||
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<Email> = 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<Vec<(String, String)>, 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)
|
||||
|
||||
@@ -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<PgPool>,
|
||||
repo: Arc<dyn FavoritesRepositoryPort>,
|
||||
}
|
||||
|
||||
impl FavoritesService {
|
||||
/// Create a new FavoritesService with the given database pool
|
||||
pub fn new(db_pool: Arc<PgPool>) -> Self {
|
||||
Self { db_pool }
|
||||
/// Create a new FavoritesService with the given repository port
|
||||
pub fn new(repo: Arc<dyn FavoritesRepositoryPort>) -> 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<Vec<FavoriteItemDto>> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<dyn FileWritePort>,
|
||||
file_read: Option<Arc<dyn FileReadPort>>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
dedup_service: Option<Arc<dyn DedupPort>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
/// Creates a new file management service
|
||||
/// Backward-compatible constructor (no trash, no dedup).
|
||||
pub fn new(file_repository: Arc<dyn FileWritePort>) -> 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<dyn FileWritePort>,
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
dedup_service: Arc<dyn DedupPort>,
|
||||
) -> 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<dyn TrashUseCase>) -> 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<String> {
|
||||
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<String>) -> Result<FileDto, DomainError> {
|
||||
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<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
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<bool, DomainError> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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<dyn FileReadPort>,
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
write_behind: Option<Arc<dyn WriteBehindCachePort>>,
|
||||
content_cache: Option<Arc<dyn ContentCachePort>>,
|
||||
transcode: Option<Arc<dyn ImageTranscodePort>>,
|
||||
}
|
||||
|
||||
impl FileRetrievalService {
|
||||
/// Crea un nuevo servicio de recuperación de archivos
|
||||
/// Backward-compatible constructor (simple pass-through).
|
||||
pub fn new(file_repository: Arc<dyn FileReadPort>) -> 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<dyn FileReadPort>,
|
||||
write_behind: Arc<dyn WriteBehindCachePort>,
|
||||
content_cache: Arc<dyn ContentCachePort>,
|
||||
transcode: Arc<dyn ImageTranscodePort>,
|
||||
) -> 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<FileDto, DomainError> {
|
||||
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<FileDto, DomainError> {
|
||||
// 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<Vec<FileDto>, 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<Vec<u8>, 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_repository.get_file_stream(id).await
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
}
|
||||
@@ -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<FileRepositoryError> 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<DomainError> 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<FileServiceError> 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<T> = Result<T, FileServiceError>;
|
||||
|
||||
/**
|
||||
* 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<dyn FileStoragePort>,
|
||||
}
|
||||
|
||||
impl FileService {
|
||||
/// Creates a new file service
|
||||
pub fn new(file_repository: Arc<dyn FileStoragePort>) -> 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<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn get_file(&self, _id: &str) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result<FileDto, DomainError> {
|
||||
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<Vec<FileDto>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let empty_stream = futures::stream::empty();
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn move_file(&self, _file_id: &str, _folder_id: Option<String>) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
}
|
||||
|
||||
FileServiceStub
|
||||
}
|
||||
|
||||
/// Uploads a new file from bytes
|
||||
pub async fn upload_file_from_bytes(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> FileServiceResult<FileDto>
|
||||
{
|
||||
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<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>>,
|
||||
) -> FileServiceResult<FileDto>
|
||||
{
|
||||
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<String>,
|
||||
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<FileDto> {
|
||||
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<FileDto> {
|
||||
// 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<FileDto> {
|
||||
// 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<Vec<FileDto>> {
|
||||
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<Vec<u8>> {
|
||||
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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>
|
||||
) -> FileServiceResult<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<Bytes> {
|
||||
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<String>) -> FileServiceResult<FileDto> {
|
||||
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<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
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<FileDto, DomainError> {
|
||||
FileService::get_file(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
|
||||
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<FileDto, DomainError> {
|
||||
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<Vec<FileDto>, 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<Vec<u8>, DomainError> {
|
||||
FileService::get_file_content(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
FileService::get_file_stream(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError> {
|
||||
FileService::move_file(self, file_id, folder_id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
// 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<dyn FileWritePort>,
|
||||
storage_usage_service: Option<Arc<dyn StorageUsagePort>>,
|
||||
/// Write port — handles save, streaming, deferred registration
|
||||
file_write: Arc<dyn FileWritePort>,
|
||||
/// Read port — needed for WebDAV create_file / update_file
|
||||
file_read: Option<Arc<dyn FileReadPort>>,
|
||||
/// Optional write-behind cache for instant uploads
|
||||
write_behind: Option<Arc<dyn WriteBehindCachePort>>,
|
||||
/// Optional dedup service for content-addressable storage
|
||||
dedup: Option<Arc<dyn DedupPort>>,
|
||||
/// Optional storage usage tracking
|
||||
storage_usage_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>,
|
||||
}
|
||||
|
||||
impl FileUploadService {
|
||||
/// Crea un nuevo servicio de subida de archivos
|
||||
/// Backward-compatible constructor (no write-behind, no dedup).
|
||||
pub fn new(file_repository: Arc<dyn FileWritePort>) -> 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<dyn FileWritePort>,
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
write_behind: Arc<dyn WriteBehindCachePort>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
) -> 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<dyn StorageUsagePort>
|
||||
mut self,
|
||||
storage_usage_service: Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>,
|
||||
) -> 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<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// 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<String>,
|
||||
content_type: String,
|
||||
chunks: Vec<Bytes>,
|
||||
total_size: usize,
|
||||
) -> Result<(FileDto, UploadStrategy), DomainError> {
|
||||
use futures::stream;
|
||||
|
||||
// ─── Dedup (runs for all tiers) ──────────────────────
|
||||
{
|
||||
let dedup_data: Vec<u8> = {
|
||||
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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<FileDto, DomainError> {
|
||||
// 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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PgPool>,
|
||||
max_recent_items: i32, // Número máximo de elementos recientes a mantener por usuario
|
||||
repo: Arc<dyn RecentItemsRepositoryPort>,
|
||||
max_recent_items: i32,
|
||||
}
|
||||
|
||||
impl RecentService {
|
||||
/// Crear un nuevo servicio de elementos recientes
|
||||
pub fn new(db_pool: Arc<PgPool>, 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<dyn RecentItemsRepositoryPort>, 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<i32>) -> Result<Vec<RecentItemDto>> {
|
||||
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<bool> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
|
||||
/// Repositorio para operaciones con carpetas
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
@@ -66,7 +67,7 @@ impl SearchService {
|
||||
* @param max_cache_size Tamaño máximo de la caché
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
cache_ttl: u64,
|
||||
max_cache_size: usize,
|
||||
|
||||
@@ -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<ShareServiceError> for DomainError {
|
||||
pub struct ShareService {
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
) -> 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<String, DomainError> {
|
||||
Ok(format!("hashed_{}", password))
|
||||
}
|
||||
|
||||
fn verify_password(&self, _password: &str, _hash: &str) -> Result<bool, DomainError> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockFileRepository;
|
||||
struct MockFolderRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FileStoragePort for MockFileRepository {
|
||||
async fn find_file_by_id(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
impl FileReadPort for MockFileRepository {
|
||||
async fn get_file(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
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<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_file(&self, _file: &crate::domain::entities::file::File) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn find_all_files(&self) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, _id: &str) -> Result<bytes::Bytes, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderStoragePort for MockFolderRepository {
|
||||
async fn find_folder_by_id(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
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<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
|
||||
async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_folder(&self, _folder: &crate::domain::entities::folder::Folder) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(&self, _parent_id: Option<&str>, _offset: usize, _limit: usize, _include_total: bool) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn find_all_folders(&self) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
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<Share, DomainError> {
|
||||
@@ -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<Vec<Share>, DomainError> {
|
||||
@@ -484,7 +562,7 @@ mod tests {
|
||||
|
||||
let type_str = item_type.to_string();
|
||||
let result: Vec<Share> = 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<Share, DomainError> {
|
||||
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<Share> = 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/"));
|
||||
}
|
||||
}
|
||||
@@ -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<FolderRepositoryError> 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<FileRepositoryError> 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<T> = Result<T, StorageMediatorError>;
|
||||
|
||||
@@ -98,113 +71,20 @@ pub trait StorageMediator: Send + Sync + 'static {
|
||||
|
||||
/// Implementación concreta del mediador de almacenamiento
|
||||
pub struct FileSystemStorageMediator {
|
||||
pub folder_repository: Arc<dyn FolderRepository>,
|
||||
pub folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
pub path_service: Arc<dyn StoragePort>,
|
||||
pub id_mapping: Arc<dyn IdMappingPort>,
|
||||
}
|
||||
|
||||
impl FileSystemStorageMediator {
|
||||
pub fn new(folder_repository: Arc<dyn FolderRepository>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> Self {
|
||||
Self { folder_repository, path_service, id_mapping }
|
||||
pub fn new(folder_storage_port: Arc<dyn FolderStoragePort>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> 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<RwLock<Option<Arc<dyn FolderRepository>>>>,
|
||||
path_service: Arc<dyn StoragePort>,
|
||||
id_mapping: Arc<dyn IdMappingPort>
|
||||
) -> 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<String>) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn get_folder_by_id(&self, _id: &str) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn get_folder_by_storage_path(&self, _storage_path: &StoragePath) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<Folder>, 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<Folder>, Option<usize>), FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<Folder, FolderRepositoryError> {
|
||||
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<bool, FolderRepositoryError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(&self, _id: &str) -> Result<StoragePath, FolderRepositoryError> {
|
||||
Ok(StoragePath::root())
|
||||
}
|
||||
|
||||
// Legacy methods
|
||||
#[allow(deprecated)]
|
||||
async fn folder_exists(&self, _path: &std::path::PathBuf) -> Result<bool, FolderRepositoryError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> Result<Folder, FolderRepositoryError> {
|
||||
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<PathBuf> {
|
||||
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<StoragePath> {
|
||||
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<Folder> {
|
||||
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)
|
||||
|
||||
@@ -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<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
user_repository: Arc<dyn UserStoragePort>,
|
||||
}
|
||||
|
||||
impl StorageUsageService {
|
||||
/// Creates a new storage usage service
|
||||
pub fn new(
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
user_repository: Arc<dyn UserStoragePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -90,7 +89,7 @@ impl StorageUsageService {
|
||||
async fn calculate_folder_size(&self, folder_id: &str) -> Result<i64, DomainError> {
|
||||
// Implementation with explicit boxing to handle recursion in async functions
|
||||
async fn inner_calculate_size(
|
||||
repo: Arc<dyn FileStoragePort>,
|
||||
repo: Arc<dyn FileReadPort>,
|
||||
folder_id: &str,
|
||||
) -> Result<i64, DomainError> {
|
||||
let mut total_size: i64 = 0;
|
||||
|
||||
@@ -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<dyn TrashRepository>,
|
||||
|
||||
/// Repository for file operations used when trashing, restoring, or deleting files
|
||||
file_repository: Arc<dyn FileRepository>,
|
||||
/// Port for file read operations (get file metadata)
|
||||
file_read_port: Arc<dyn FileReadPort>,
|
||||
|
||||
/// Repository for folder operations used when trashing, restoring, or deleting folders
|
||||
folder_repository: Arc<dyn FolderRepository>,
|
||||
/// Port for file write operations (trash, restore, delete)
|
||||
file_write_port: Arc<dyn FileWritePort>,
|
||||
|
||||
/// Port for folder operations (get folder, trash, restore, delete)
|
||||
folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
|
||||
/// 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<dyn TrashRepository>,
|
||||
file_repository: Arc<dyn FileRepository>,
|
||||
folder_repository: Arc<dyn FolderRepository>,
|
||||
file_read_port: Arc<dyn FileReadPort>,
|
||||
file_write_port: Arc<dyn FileWritePort>,
|
||||
folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<TrashedItem>> {
|
||||
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<Option<TrashedItem>> {
|
||||
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<File> {
|
||||
impl FileReadPort for MockFileRepository {
|
||||
async fn get_file(&self, id: &str) -> std::result::Result<File, DomainError> {
|
||||
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<Vec<File>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> std::result::Result<Vec<u8>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, _id: &str) -> std::result::Result<Bytes, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> std::result::Result<StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileWritePort for MockFileRepository {
|
||||
async fn save_file(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_stream: std::pin::Pin<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content(&self, _file_id: &str, _content: Vec<u8>) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_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<Vec<File>> { 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<Vec<u8>> { 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<Folder> {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> std::result::Result<Folder, DomainError> {
|
||||
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<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
) -> std::result::Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
Ok((vec![], Some(0)))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &StoragePath) -> std::result::Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, _id: &str) -> std::result::Result<StoragePath, DomainError> {
|
||||
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<Vec<Folder>> { 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<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
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<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
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<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
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<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
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<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
@@ -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<dyn FileStoragePort>,
|
||||
}
|
||||
|
||||
impl DomainFileRepoAdapter {
|
||||
pub fn new(repo: Arc<dyn FileStoragePort>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileRepository for DomainFileRepoAdapter {
|
||||
async fn save_file_from_bytes(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> FileRepositoryResult<File> {
|
||||
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<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>>,
|
||||
) -> FileRepositoryResult<File> {
|
||||
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<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> FileRepositoryResult<File> {
|
||||
Err(FileRepositoryError::Other("Not implemented".to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult<File> {
|
||||
self.repo.get_file(id)
|
||||
.await
|
||||
.map_err(|e| FileRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> FileRepositoryResult<Vec<File>> {
|
||||
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<Vec<u8>> {
|
||||
self.repo.get_file_content(id)
|
||||
.await
|
||||
.map_err(|e| FileRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn get_file_stream(&self, id: &str) -> FileRepositoryResult<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + 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<u64>
|
||||
) -> FileRepositoryResult<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + 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<bytes::Bytes> {
|
||||
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<String>) -> FileRepositoryResult<File> {
|
||||
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<StoragePath> {
|
||||
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<String> = 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<u8>) -> 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<dyn FolderStoragePort>,
|
||||
}
|
||||
|
||||
impl DomainFolderRepoAdapter {
|
||||
pub fn new(repo: Arc<dyn FolderStoragePort>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderRepository for DomainFolderRepoAdapter {
|
||||
async fn create_folder(&self, name: String, parent_id: Option<String>) -> FolderRepositoryResult<Folder> {
|
||||
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<Folder> {
|
||||
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<Folder> {
|
||||
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<Vec<Folder>> {
|
||||
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<Folder>, Option<usize>)> {
|
||||
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<Folder> {
|
||||
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<Folder> {
|
||||
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<bool> {
|
||||
self.repo.folder_exists(storage_path)
|
||||
.await
|
||||
.map_err(|e| FolderRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult<StoragePath> {
|
||||
self.repo.get_folder_path(id)
|
||||
.await
|
||||
.map_err(|e| FolderRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, _path: &std::path::PathBuf) -> FolderRepositoryResult<bool> {
|
||||
Err(FolderRepositoryError::Other("Not implemented".to_string()))
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> FolderRepositoryResult<Folder> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -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::<i64>()) {
|
||||
|
||||
+332
-639
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -1,6 +1,4 @@
|
||||
pub mod errors;
|
||||
pub mod config;
|
||||
pub mod di;
|
||||
pub mod db;
|
||||
pub mod auth_factory;
|
||||
pub mod adapters;
|
||||
pub mod stubs;
|
||||
@@ -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<Vec<u8>, 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<Vec<u8>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn decompress_data(&self, _compressed_data: &[u8]) -> Result<Vec<u8>, 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<String, DomainError> {
|
||||
Ok("dummy-id".to_string())
|
||||
}
|
||||
|
||||
async fn get_path_by_id(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
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<PathBuf, StorageMediatorError> {
|
||||
Ok(PathBuf::from("/tmp"))
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> Result<StoragePath, StorageMediatorError> {
|
||||
Ok(StoragePath::root())
|
||||
}
|
||||
|
||||
async fn get_folder(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> Result<Folder, StorageMediatorError> {
|
||||
Err(StorageMediatorError::NotFound(
|
||||
"Stub not implemented".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn file_exists_at_path(
|
||||
&self,
|
||||
_path: &Path,
|
||||
) -> Result<bool, StorageMediatorError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn file_exists_at_storage_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> Result<bool, StorageMediatorError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn folder_exists_at_path(
|
||||
&self,
|
||||
_path: &Path,
|
||||
) -> Result<bool, StorageMediatorError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn folder_exists_at_storage_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> Result<bool, StorageMediatorError> {
|
||||
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<String, DomainError> {
|
||||
Ok("stub-id".to_string())
|
||||
}
|
||||
async fn get_path_by_id(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
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<File, DomainError> {
|
||||
Ok(File::default())
|
||||
}
|
||||
|
||||
async fn list_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let empty_stream = futures::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let empty_stream = futures::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, _id: &str) -> Result<Bytes, DomainError> {
|
||||
Ok(Bytes::new())
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
Ok(StoragePath::from_string("/"))
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||
Ok("root".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileWritePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct StubFileWritePort;
|
||||
|
||||
#[async_trait]
|
||||
impl FileWritePort for StubFileWritePort {
|
||||
async fn save_file(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> Result<File, DomainError> {
|
||||
Ok(File::default())
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError> {
|
||||
Ok(File::default())
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
Ok(File::default())
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_content: Vec<u8>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_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<String>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
Ok(Folder::default())
|
||||
}
|
||||
|
||||
async fn get_folder(&self, _id: &str) -> Result<Folder, DomainError> {
|
||||
Ok(Folder::default())
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> Result<Folder, DomainError> {
|
||||
Ok(Folder::default())
|
||||
}
|
||||
|
||||
async fn list_folders(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
Ok((Vec::new(), Some(0)))
|
||||
}
|
||||
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_new_name: String,
|
||||
) -> Result<Folder, DomainError> {
|
||||
Ok(Folder::default())
|
||||
}
|
||||
|
||||
async fn move_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_new_parent_id: Option<&str>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
Ok(Folder::default())
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn get_folder_path(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<StoragePath, DomainError> {
|
||||
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<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn load_translations(&self, _locale: Locale) -> I18nResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn available_locales(&self) -> Vec<Locale> {
|
||||
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<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
async fn get_folder(&self, _id: &str) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_path: &str,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
async fn list_folders(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_pagination: &PaginationRequestDto,
|
||||
) -> Result<PaginatedResponseDto<FolderDto>, DomainError> {
|
||||
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
|
||||
}
|
||||
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_dto: RenameFolderDto,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
async fn move_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_dto: MoveFolderDto,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
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<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn smart_upload(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_chunks: Vec<Bytes>,
|
||||
_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<FileDto, DomainError> {
|
||||
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<FileDto, DomainError> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn list_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let empty_stream = futures::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
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<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let empty_stream = futures::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
|
||||
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<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
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<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileUseCaseFactory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct StubFileUseCaseFactory;
|
||||
|
||||
impl FileUseCaseFactory for StubFileUseCaseFactory {
|
||||
fn create_file_upload_use_case(&self) -> Arc<dyn FileUploadUseCase> {
|
||||
Arc::new(StubFileUploadUseCase)
|
||||
}
|
||||
|
||||
fn create_file_retrieval_use_case(&self) -> Arc<dyn FileRetrievalUseCase> {
|
||||
Arc::new(StubFileRetrievalUseCase)
|
||||
}
|
||||
|
||||
fn create_file_management_use_case(&self) -> Arc<dyn FileManagementUseCase> {
|
||||
Arc::new(StubFileManagementUseCase)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearchUseCase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct StubSearchUseCase;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchUseCase for StubSearchUseCase {
|
||||
async fn search(
|
||||
&self,
|
||||
_criteria: SearchCriteriaDto,
|
||||
) -> Result<SearchResultsDto, DomainError> {
|
||||
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<CachedMetadataDto> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn is_file(&self, _path: &Path) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn refresh_metadata(&self, path: &Path) -> Result<CachedMetadataDto, DomainError> {
|
||||
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) {}
|
||||
}
|
||||
+294
-32
@@ -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<String>,
|
||||
pub color: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
id: Uuid,
|
||||
name: String,
|
||||
owner_id: String,
|
||||
description: Option<String>,
|
||||
color: Option<String>,
|
||||
is_public: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AddressBook {
|
||||
/// Creates a new AddressBook with generated id and timestamps
|
||||
pub fn new(
|
||||
name: String,
|
||||
owner_id: String,
|
||||
description: Option<String>,
|
||||
color: Option<String>,
|
||||
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<String>,
|
||||
color: Option<String>,
|
||||
is_public: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
) -> 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<Utc> { &self.created_at }
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> { &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<String>) { self.description = description; self.updated_at = Utc::now(); }
|
||||
pub fn set_color(&mut self, color: Option<String>) { 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<Utc>) { 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<String>,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
nickname: Option<String>,
|
||||
email: Vec<Email>,
|
||||
phone: Vec<Phone>,
|
||||
address: Vec<Address>,
|
||||
organization: Option<String>,
|
||||
title: Option<String>,
|
||||
notes: Option<String>,
|
||||
photo_url: Option<String>,
|
||||
birthday: Option<NaiveDate>,
|
||||
anniversary: Option<NaiveDate>,
|
||||
vcard: String,
|
||||
etag: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
nickname: Option<String>,
|
||||
email: Vec<Email>,
|
||||
phone: Vec<Phone>,
|
||||
address: Vec<Address>,
|
||||
organization: Option<String>,
|
||||
title: Option<String>,
|
||||
notes: Option<String>,
|
||||
photo_url: Option<String>,
|
||||
birthday: Option<NaiveDate>,
|
||||
anniversary: Option<NaiveDate>,
|
||||
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<String>,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
nickname: Option<String>,
|
||||
email: Vec<Email>,
|
||||
phone: Vec<Phone>,
|
||||
address: Vec<Address>,
|
||||
organization: Option<String>,
|
||||
title: Option<String>,
|
||||
notes: Option<String>,
|
||||
photo_url: Option<String>,
|
||||
birthday: Option<NaiveDate>,
|
||||
anniversary: Option<NaiveDate>,
|
||||
vcard: String,
|
||||
etag: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
) -> 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<Utc> { &self.created_at }
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> { &self.updated_at }
|
||||
|
||||
// --- Owned getters for persistence layer bind() calls ---
|
||||
pub fn full_name_owned(&self) -> Option<String> { self.full_name.clone() }
|
||||
pub fn first_name_owned(&self) -> Option<String> { self.first_name.clone() }
|
||||
pub fn last_name_owned(&self) -> Option<String> { self.last_name.clone() }
|
||||
pub fn nickname_owned(&self) -> Option<String> { self.nickname.clone() }
|
||||
pub fn organization_owned(&self) -> Option<String> { self.organization.clone() }
|
||||
pub fn title_owned(&self) -> Option<String> { self.title.clone() }
|
||||
pub fn notes_owned(&self) -> Option<String> { self.notes.clone() }
|
||||
pub fn photo_url_owned(&self) -> Option<String> { self.photo_url.clone() }
|
||||
|
||||
// --- Setters for mutable operations (contact_service.rs needs these) ---
|
||||
pub fn set_full_name(&mut self, v: Option<String>) { self.full_name = v; }
|
||||
pub fn set_first_name(&mut self, v: Option<String>) { self.first_name = v; }
|
||||
pub fn set_last_name(&mut self, v: Option<String>) { self.last_name = v; }
|
||||
pub fn set_nickname(&mut self, v: Option<String>) { self.nickname = v; }
|
||||
pub fn set_organization(&mut self, v: Option<String>) { self.organization = v; }
|
||||
pub fn set_title(&mut self, v: Option<String>) { self.title = v; }
|
||||
pub fn set_notes(&mut self, v: Option<String>) { self.notes = v; }
|
||||
pub fn set_photo_url(&mut self, v: Option<String>) { self.photo_url = v; }
|
||||
pub fn set_birthday(&mut self, v: Option<NaiveDate>) { self.birthday = v; }
|
||||
pub fn set_anniversary(&mut self, v: Option<NaiveDate>) { 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<Utc>) { 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<Email>) { self.email = email; }
|
||||
pub fn set_phone(&mut self, phone: Vec<Phone>) { self.phone = phone; }
|
||||
pub fn set_address(&mut self, address: Vec<Address>) { 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<Email> { 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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
id: Uuid,
|
||||
address_book_id: Uuid,
|
||||
name: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
) -> 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<Utc> { &self.created_at }
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> { &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<Utc>) { 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())
|
||||
}
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub revoked: bool,
|
||||
id: String,
|
||||
user_id: String,
|
||||
refresh_token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
revoked: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -21,6 +21,13 @@ impl Session {
|
||||
user_agent: Option<String>,
|
||||
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<Utc>,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
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<Utc> {
|
||||
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<Utc> {
|
||||
self.created_at
|
||||
|
||||
+149
-35
@@ -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<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
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<String>,
|
||||
expires_at: Option<u64>,
|
||||
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<String>,
|
||||
expires_at: Option<u64>,
|
||||
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<u64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub deletion_date: DateTime<Utc>, // 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<Utc>,
|
||||
deletion_date: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<Utc>,
|
||||
deletion_date: DateTime<Utc>,
|
||||
) -> 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<Utc> {
|
||||
self.trashed_at
|
||||
}
|
||||
|
||||
pub fn deletion_date(&self) -> DateTime<Utc> {
|
||||
self.deletion_date
|
||||
}
|
||||
|
||||
pub fn days_until_deletion(&self) -> i64 {
|
||||
let now = Utc::now();
|
||||
(self.deletion_date - now).num_days().max(0)
|
||||
|
||||
@@ -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<File, DomainError>;
|
||||
|
||||
/// Lista archivos en una carpeta.
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
|
||||
|
||||
/// Obtiene contenido completo como bytes (solo archivos pequeños/medianos).
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
|
||||
|
||||
/// Obtiene contenido como stream (ideal para archivos grandes).
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Memory-map de archivo para acceso zero-copy (10–100 MB).
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError>;
|
||||
|
||||
/// Obtiene la ruta de almacenamiento lógica de un archivo.
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
/// Obtiene el ID de la carpeta padre a partir de una ruta (WebDAV).
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> = Result<T, FileRepositoryError>;
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 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<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> FileRepositoryResult<File>;
|
||||
|
||||
/**
|
||||
* 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<File, DomainError>;
|
||||
|
||||
/// Upload en streaming — escribe chunks a disco sin acumular en RAM.
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> FileRepositoryResult<File>;
|
||||
|
||||
/**
|
||||
* 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// Mueve un archivo a otra carpeta.
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// 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<u8>) -> 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<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> FileRepositoryResult<File>;
|
||||
|
||||
/**
|
||||
* 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<File>;
|
||||
|
||||
/**
|
||||
* 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<Vec<File>>;
|
||||
|
||||
/**
|
||||
* 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<Vec<u8>>;
|
||||
|
||||
/**
|
||||
* 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>
|
||||
) -> FileRepositoryResult<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<Bytes>;
|
||||
|
||||
/**
|
||||
* 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<String>) -> FileRepositoryResult<File>;
|
||||
|
||||
/**
|
||||
* 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<StoragePath>;
|
||||
|
||||
/**
|
||||
* 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<u8>) -> FileRepositoryResult<()>;
|
||||
}
|
||||
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<T: FileReadRepository + FileWriteRepository> FileRepository for T {}
|
||||
|
||||
@@ -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<T> = Result<T, FolderRepositoryError>;
|
||||
|
||||
/// 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<String>) -> FolderRepositoryResult<Folder>;
|
||||
/// Crea una nueva carpeta
|
||||
async fn create_folder(&self, name: String, parent_id: Option<String>) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Gets a folder by its ID
|
||||
async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult<Folder>;
|
||||
/// Obtiene una carpeta por su ID
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Gets a folder by its path
|
||||
async fn get_folder_by_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult<Folder>;
|
||||
/// Obtiene una carpeta por su ruta de almacenamiento
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Lists all folders in a parent folder (use with caution for large directories)
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> FolderRepositoryResult<Vec<Folder>>;
|
||||
/// Lista carpetas dentro de una carpeta padre
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, 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<Folder>, Option<usize>)>;
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
|
||||
|
||||
/// Renames a folder
|
||||
async fn rename_folder(&self, id: &str, new_name: String) -> FolderRepositoryResult<Folder>;
|
||||
/// Renombra una carpeta
|
||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Moves a folder to a new parent
|
||||
async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> FolderRepositoryResult<Folder>;
|
||||
/// Mueve una carpeta a otro padre
|
||||
async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result<Folder, DomainError>;
|
||||
|
||||
/// 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<bool>;
|
||||
/// Verifica si existe una carpeta en la ruta dada
|
||||
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
|
||||
/// Gets the storage path for a folder
|
||||
async fn get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult<StoragePath>;
|
||||
|
||||
/// 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<bool>;
|
||||
|
||||
/// 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<Folder>;
|
||||
|
||||
/// 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<()>;
|
||||
}
|
||||
/// Obtiene la ruta de una carpeta
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
// ── 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>;
|
||||
}
|
||||
|
||||
@@ -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<AddressBookDto, DomainError> {
|
||||
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<Vec<(String, String)>, 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod adapters;
|
||||
pub mod auth_factory;
|
||||
pub mod db;
|
||||
pub mod repositories;
|
||||
pub mod services;
|
||||
|
||||
|
||||
@@ -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<dyn FileReadPort>` + `Arc<dyn FileWritePort>`
|
||||
/// y delega cada método al port correspondiente.
|
||||
///
|
||||
/// Gracias al blanket impl `impl<T: FileReadPort + FileWritePort> FileStoragePort for T {}`
|
||||
/// este tipo obtiene `FileStoragePort` automáticamente.
|
||||
pub struct CompositeFileRepository {
|
||||
read: Arc<dyn FileReadPort>,
|
||||
write: Arc<dyn FileWritePort>,
|
||||
}
|
||||
|
||||
impl CompositeFileRepository {
|
||||
pub fn new(read: Arc<dyn FileReadPort>, write: Arc<dyn FileWritePort>) -> Self {
|
||||
Self { read, write }
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// FileReadPort — delegate to self.read
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FileReadPort for CompositeFileRepository {
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
self.read.get_file(id).await
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
self.read.list_files(folder_id).await
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
self.read.get_file_content(id).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
||||
self.read.get_file_mmap(id).await
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
self.read.get_file_path(id).await
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
||||
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<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<File, DomainError> {
|
||||
self.write.save_file(name, folder_id, content_type, content).await
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError> {
|
||||
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<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
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<u8>) -> Result<(), DomainError> {
|
||||
self.write.update_file_content(file_id, content).await
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<FileMetadataManager>,
|
||||
path_resolver: Arc<FilePathResolver>,
|
||||
root_path: PathBuf,
|
||||
storage_mediator: Arc<dyn StorageMediator>,
|
||||
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
path_service: Arc<PathService>,
|
||||
metadata_cache: Arc<dyn MetadataCachePort>,
|
||||
config: AppConfig,
|
||||
parallel_processor: Option<Arc<ParallelFileProcessor>>,
|
||||
}
|
||||
|
||||
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<FileMetadataManager>,
|
||||
path_resolver: Arc<FilePathResolver>,
|
||||
_config: AppConfig,
|
||||
_parallel_processor: Option<Arc<ParallelFileProcessor>>,
|
||||
root_path: PathBuf,
|
||||
storage_mediator: Arc<dyn StorageMediator>,
|
||||
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
path_service: Arc<PathService>,
|
||||
metadata_cache: Arc<dyn MetadataCachePort>,
|
||||
config: AppConfig,
|
||||
parallel_processor: Option<Arc<ParallelFileProcessor>>,
|
||||
) -> 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<dyn MetadataCachePort>,
|
||||
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<String>,
|
||||
created_at: Option<u64>,
|
||||
modified_at: Option<u64>,
|
||||
) -> FileRepositoryResult<File> {
|
||||
// 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<File> {
|
||||
// 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<String> = 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<File, DomainError> {
|
||||
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<Vec<File>, 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<Vec<File>, 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<Vec<u8>, 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<Vec<u8>> {
|
||||
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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<Bytes, DomainError> {
|
||||
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<Bytes, DomainError> {
|
||||
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<StoragePath, DomainError> {
|
||||
self.id_mapping_service.get_path_by_id(id).await
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PathBuf> {
|
||||
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;
|
||||
@@ -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<FileMetadataManager>,
|
||||
path_resolver: Arc<FilePathResolver>,
|
||||
root_path: PathBuf,
|
||||
storage_mediator: Arc<dyn StorageMediator>,
|
||||
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
path_service: Arc<PathService>,
|
||||
metadata_cache: Arc<dyn MetadataCachePort>,
|
||||
config: AppConfig,
|
||||
parallel_processor: Option<Arc<ParallelFileProcessor>>,
|
||||
}
|
||||
|
||||
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<FileMetadataManager>,
|
||||
path_resolver: Arc<FilePathResolver>,
|
||||
_storage_mediator: Arc<dyn StorageMediator>,
|
||||
root_path: PathBuf,
|
||||
storage_mediator: Arc<dyn StorageMediator>,
|
||||
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
path_service: Arc<PathService>,
|
||||
metadata_cache: Arc<dyn MetadataCachePort>,
|
||||
config: AppConfig,
|
||||
_parallel_processor: Option<Arc<ParallelFileProcessor>>,
|
||||
parallel_processor: Option<Arc<ParallelFileProcessor>>,
|
||||
) -> 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<dyn MetadataCachePort>,
|
||||
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<String>,
|
||||
created_at: Option<u64>,
|
||||
modified_at: Option<u64>,
|
||||
) -> FileRepositoryResult<File> {
|
||||
// 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<bool> {
|
||||
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<String>) -> 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<u8>,
|
||||
) -> Result<File, DomainError> {
|
||||
// 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<String>) -> Result<File, DomainError> {
|
||||
// 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<String>,
|
||||
content_type: String,
|
||||
mut stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, 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 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<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
// 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<File, DomainError> {
|
||||
// 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<u8>) -> 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<String, DomainError> {
|
||||
// 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<String>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<FileMetadataCache>,
|
||||
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<MetadataError> 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<FileMetadataCache>, 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<bool, MetadataError> {
|
||||
// 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<File, MetadataError> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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<PathService>,
|
||||
storage_mediator: Arc<dyn StorageMediator>,
|
||||
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
}
|
||||
|
||||
impl FilePathResolver {
|
||||
/// Crea un nuevo resolver de rutas
|
||||
pub fn new(
|
||||
path_service: Arc<PathService>,
|
||||
storage_mediator: Arc<dyn StorageMediator>,
|
||||
id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
) -> 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<String, DomainError> {
|
||||
Ok("dummy-id".to_string())
|
||||
}
|
||||
|
||||
async fn get_path_by_id(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
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<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<StoragePath, FileRepositoryError> {
|
||||
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<String, FileRepositoryError> {
|
||||
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<StoragePath, DomainError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<dyn crate::application::ports::outbound::IdMappingPort> {
|
||||
&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<StoragePath> {
|
||||
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<String> {
|
||||
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<IdMappingError> 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<FolderRepositoryError> 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<String>) -> Result<Folder, DomainError> {
|
||||
FolderRepository::create_folder(self, name, parent_id).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
FolderRepository::get_folder_by_id(self, id).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
||||
FolderRepository::get_folder_by_storage_path(self, storage_path).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
||||
FolderRepository::list_folders(self, parent_id).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
||||
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<Folder, DomainError> {
|
||||
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<bool, DomainError> {
|
||||
FolderRepository::folder_exists_at_storage_path(self, storage_path).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
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<Folder>, Option<usize>), 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<String>) -> FolderRepositoryResult<Folder> {
|
||||
async fn create_folder(&self, name: String, parent_id: Option<String>) -> Result<Folder, DomainError> {
|
||||
// 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<Folder> {
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
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<String> = 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<Folder> {
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
||||
// 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<String> = 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<Vec<Folder>> {
|
||||
use futures::stream::{StreamExt};
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, 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<Folder>, Option<usize>)> {
|
||||
) -> Result<(Vec<Folder>, Option<usize>), 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<Folder> {
|
||||
// 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<Folder, DomainError> {
|
||||
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<Folder> {
|
||||
// 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<Folder, DomainError> {
|
||||
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<bool> {
|
||||
self.check_folder_exists_at_storage_path(storage_path).await
|
||||
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
self.check_folder_exists_at_storage_path(storage_path).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult<StoragePath> {
|
||||
// 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<StoragePath, DomainError> {
|
||||
self._get_folder_storage_path(id).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
// Legacy method implementations
|
||||
|
||||
async fn folder_exists(&self, path: &std::path::PathBuf) -> FolderRepositoryResult<bool> {
|
||||
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<Folder> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
use crate::infrastructure::repositories::repository_errors::FolderRepositoryError;
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Vec<u8>, 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]
|
||||
|
||||
@@ -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<AddressBook> {
|
||||
@@ -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)
|
||||
|
||||
@@ -23,9 +23,9 @@ impl ContactPgRepository {
|
||||
impl ContactRepository for ContactPgRepository {
|
||||
async fn create_contact(&self, contact: Contact) -> ContactRepositoryResult<Contact> {
|
||||
// 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<Contact> {
|
||||
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)))?;
|
||||
|
||||
@@ -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<PgPool>,
|
||||
}
|
||||
|
||||
impl FavoritesPgRepository {
|
||||
pub fn new(db_pool: Arc<PgPool>) -> Self {
|
||||
Self { db_pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
async fn get_favorites(&self, user_id: &str) -> Result<Vec<FavoriteItemDto>> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PgPool>,
|
||||
}
|
||||
|
||||
impl RecentItemsPgRepository {
|
||||
pub fn new(db_pool: Arc<PgPool>) -> Self {
|
||||
Self { db_pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
async fn get_recent_items(&self, user_id: &str, limit: i32) -> Result<Vec<RecentItemDto>> {
|
||||
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<bool> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<T> = Result<T, FileRepositoryError>;
|
||||
|
||||
/// 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<T> = Result<T, FolderRepositoryError>;
|
||||
|
||||
// ── Conversions to DomainError ──
|
||||
|
||||
impl From<FileRepositoryError> 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<FolderRepositoryError> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Self> 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<Self>) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
/// Timestamp de creación
|
||||
pub created_at: Option<u64>,
|
||||
/// Timestamp de modificación
|
||||
pub modified_at: Option<u64>,
|
||||
/// 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<HashMap<PathBuf, CachedMetadata>>,
|
||||
/// 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<bool, ()> {
|
||||
// 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<CachedMetadata> {
|
||||
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<u64>,
|
||||
created_at: Option<u64>, modified_at: Option<u64>, 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<PathBuf, CachedMetadata>, 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<Self>) -> 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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
content_type: String,
|
||||
total_size: u64,
|
||||
chunk_size: Option<usize>,
|
||||
) -> Result<CreateUploadResponseDto, DomainError> {
|
||||
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<String>,
|
||||
) -> Result<ChunkUploadResponseDto, DomainError> {
|
||||
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<UploadStatusResponseDto, DomainError> {
|
||||
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>, 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::*;
|
||||
|
||||
@@ -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<PortCompressionLevel> 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<Vec<u8>, DomainError> {
|
||||
CompressionService::compress_data(self, data, level.into()).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn decompress_data(&self, compressed_data: &[u8]) -> Result<Vec<u8>, 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));
|
||||
}
|
||||
}
|
||||
@@ -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<DedupResult> 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<BlobMetadata> 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<DedupStats> 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<String>,
|
||||
) -> Result<DedupResultDto, DomainError> {
|
||||
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<String>,
|
||||
) -> Result<DedupResultDto, DomainError> {
|
||||
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<BlobMetadataDto> {
|
||||
self.get_blob_metadata(hash).await.map(Into::into)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, DomainError> {
|
||||
self.read_blob(hash).await
|
||||
.map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e))
|
||||
}
|
||||
|
||||
async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError> {
|
||||
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<bool, DomainError> {
|
||||
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<String, DomainError> {
|
||||
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<Vec<String>, 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();
|
||||
|
||||
@@ -213,6 +213,34 @@ pub struct CacheStats {
|
||||
/// Thread-safe wrapper for sharing across handlers
|
||||
pub type SharedFileContentCache = Arc<FileContentCache>;
|
||||
|
||||
// ─── 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::*;
|
||||
|
||||
@@ -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<CachedMetadataDto> {
|
||||
// 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<bool> {
|
||||
FileMetadataCache::is_file(self, path).await
|
||||
}
|
||||
|
||||
async fn refresh_metadata(&self, path: &Path) -> Result<CachedMetadataDto, DomainError> {
|
||||
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());
|
||||
|
||||
@@ -515,6 +515,7 @@ impl Clone for IdMappingService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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<PortOutputFormat> 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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PortThumbnailSize> 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<Bytes, DomainError> {
|
||||
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<Self>,
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<bool, DomainError> {
|
||||
self.put_pending(file_id, content, target_path).await.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_pending(&self, file_id: &str) -> Option<Bytes> {
|
||||
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()
|
||||
|
||||
@@ -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<zip::result::ZipError> for DomainError {
|
||||
|
||||
/// Servicio para crear archivos ZIP
|
||||
pub struct ZipService {
|
||||
file_service: Arc<dyn FileUseCase>,
|
||||
file_service: Arc<dyn FileRetrievalUseCase>,
|
||||
folder_service: Arc<dyn FolderUseCase>,
|
||||
}
|
||||
|
||||
impl ZipService {
|
||||
/// Crea una nueva instancia del servicio ZIP con una referencia al servicio de archivos
|
||||
pub fn new(file_service: Arc<dyn FileUseCase>, folder_service: Arc<dyn FolderUseCase>) -> Self {
|
||||
pub fn new(file_service: Arc<dyn FileRetrievalUseCase>, folder_service: Arc<dyn FolderUseCase>) -> 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<Vec<u8>, DomainError> {
|
||||
self.create_folder_zip(folder_id, folder_name).await
|
||||
}
|
||||
}
|
||||
@@ -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::<String>() + "...");
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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<AppState> {
|
||||
// Address Book handlers
|
||||
async fn list_address_books(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<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) => {
|
||||
@@ -176,10 +179,11 @@ async fn get_address_book(
|
||||
|
||||
async fn update_address_book(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(mut update): Json<UpdateAddressBookDto>,
|
||||
) -> 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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<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) => {
|
||||
@@ -246,9 +251,10 @@ async fn delete_address_book(
|
||||
|
||||
async fn get_address_book_shares(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<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) => {
|
||||
@@ -278,10 +284,11 @@ async fn get_address_book_shares(
|
||||
|
||||
async fn share_address_book(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<String>,
|
||||
Json(mut dto): Json<ShareAddressBookDto>,
|
||||
) -> 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<AppState>,
|
||||
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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<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) => {
|
||||
@@ -388,10 +397,11 @@ async fn list_contacts(
|
||||
|
||||
async fn search_contacts(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> 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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<String>,
|
||||
Json(mut dto): Json<CreateContactDto>,
|
||||
) -> 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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<String>,
|
||||
Json(mut dto): Json<CreateContactVCardDto>,
|
||||
) -> 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<AppState>,
|
||||
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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path((_, contact_id)): Path<(String, String)>,
|
||||
Json(mut update): Json<UpdateContactDto>,
|
||||
) -> 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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<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) => {
|
||||
@@ -673,10 +690,11 @@ async fn list_groups(
|
||||
|
||||
async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(address_book_id): Path<String>,
|
||||
Json(mut dto): Json<CreateContactGroupDto>,
|
||||
) -> 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<AppState>,
|
||||
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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path((_, group_id)): Path<(String, String)>,
|
||||
Json(mut update): Json<UpdateContactGroupDto>,
|
||||
) -> 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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(contact_id): Path<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) => {
|
||||
|
||||
@@ -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<String>,
|
||||
) -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<FolderService>;
|
||||
|
||||
@@ -59,6 +58,38 @@ impl FolderHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists root folders (no parent ID)
|
||||
pub async fn list_root_folders(
|
||||
State(service): State<AppState>,
|
||||
) -> 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> 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<AppState>,
|
||||
pagination: Query<PaginationRequestDto>,
|
||||
) -> 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
pagination: Query<PaginationRequestDto>,
|
||||
) -> 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<AppState>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AppState>,
|
||||
Path(locale_code): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
Self::get_translations(State(service), locale_code).await
|
||||
}
|
||||
|
||||
/// Gets all translations for a locale
|
||||
pub async fn get_translations(
|
||||
State(_service): State<AppState>,
|
||||
|
||||
@@ -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<Arc<dyn ShareUseCase>>,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<CreateShareDto>,
|
||||
) -> 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<Arc<dyn ShareUseCase>>,
|
||||
auth_user: AuthUser,
|
||||
Query(query): Query<GetSharesQuery>,
|
||||
) -> 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);
|
||||
|
||||
|
||||
@@ -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<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
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)
|
||||
|
||||
@@ -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))
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
pub use routes::create_api_routes;
|
||||
pub use routes::create_api_routes;
|
||||
pub use routes::create_public_api_routes;
|
||||
+83
-605
@@ -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<FolderService>,
|
||||
file_service: Arc<FileService>,
|
||||
i18n_service: Option<Arc<I18nApplicationService>>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
search_service: Option<Arc<dyn SearchUseCase>>,
|
||||
share_service: Option<Arc<dyn ShareUseCase>>,
|
||||
favorites_service: Option<Arc<dyn FavoritesUseCase>>,
|
||||
recent_service: Option<Arc<dyn RecentItemsUseCase>>,
|
||||
) -> Router<crate::common::di::AppState> {
|
||||
// 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<AppState> {
|
||||
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<AppState> {
|
||||
// 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<Arc<FolderService>>| async move {
|
||||
// No parent ID means list root folders
|
||||
FolderHandler::list_folders(State(service), None).await
|
||||
}))
|
||||
.route("/paginated", get(|
|
||||
State(service): State<Arc<FolderService>>,
|
||||
pagination: Query<PaginationRequestDto>
|
||||
| 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<Arc<FolderService>>,
|
||||
Path(id): Path<String>
|
||||
| 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<Arc<FolderService>>,
|
||||
Path(id): Path<String>,
|
||||
pagination: Query<PaginationRequestDto>
|
||||
| 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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Path(id): Path<String>
|
||||
| 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<AppState>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
| 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<AppState>,
|
||||
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::<serde_json::Value>(&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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Path(id): Path<String>
|
||||
| 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
| 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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Query(params): Query<HashMap<String, String>>
|
||||
| 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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Path(id): Path<String>
|
||||
| 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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Path(id): Path<String>
|
||||
| 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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Path(id): Path<String>
|
||||
| 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<AppState>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Path(id): Path<String>
|
||||
| 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<AppState>,
|
||||
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<Arc<I18nApplicationService>>,
|
||||
axum::extract::Path(locale_code): axum::extract::Path<String>,
|
||||
| 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))
|
||||
}
|
||||
@@ -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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AuthError> {
|
||||
// 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::<String>() + "...");
|
||||
|
||||
// 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::<Claims>(
|
||||
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::<CurrentUser>() {
|
||||
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
|
||||
|
||||
@@ -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<B>(response: Response<B>) -> Response<Body>
|
||||
// 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<B>(response: Response<B>) -> Response<Body>
|
||||
where
|
||||
B: http_body::Body + Send + 'static,
|
||||
B::Data: Send + 'static,
|
||||
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod middleware;
|
||||
pub mod errors;
|
||||
|
||||
pub use api::create_api_routes;
|
||||
pub use api::create_public_api_routes;
|
||||
|
||||
+1
-2
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user