big refactoring
This commit is contained in:
Generated
+166
-423
@@ -207,22 +207,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -268,12 +252,24 @@ version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
@@ -341,6 +337,12 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.36"
|
||||
@@ -379,16 +381,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -593,6 +585,15 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -632,21 +633,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -662,16 +648,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -809,6 +785,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -967,61 +953,19 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1048,19 +992,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "0.17.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc3b69b799a03e059f6dc984c25a8bf847d8ca4cbddb079c39ede7b3d24854c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"iso8601",
|
||||
"nom",
|
||||
"nom-language",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -1163,6 +1094,34 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -1182,31 +1141,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iso8601"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -1317,6 +1251,15 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
@@ -1363,12 +1306,27 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1432,6 +1390,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -1449,41 +1417,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom-language"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1514,7 +1447,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1561,60 +1494,6 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.5+3.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oxicloud"
|
||||
version = "0.1.0"
|
||||
@@ -1624,31 +1503,31 @@ dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dotenv",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"hyper",
|
||||
"icalendar",
|
||||
"image",
|
||||
"jsonwebtoken",
|
||||
"lru",
|
||||
"md5",
|
||||
"memmap2",
|
||||
"mime_guess",
|
||||
"mockall",
|
||||
"openssl",
|
||||
"pin-project-lite",
|
||||
"quick-xml",
|
||||
"rand 0.9.2",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
@@ -1656,7 +1535,6 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
@@ -1697,7 +1575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -1775,6 +1653,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -1834,6 +1725,21 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.0"
|
||||
@@ -1865,18 +1771,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1886,17 +1782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1908,15 +1794,6 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1952,48 +1829,6 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2021,7 +1856,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"rand_core",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
@@ -2087,44 +1922,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -2245,7 +2048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2429,7 +2232,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"rand",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
@@ -2469,7 +2272,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -2547,9 +2350,6 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -2562,27 +2362,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
@@ -2715,26 +2494,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
@@ -2791,14 +2550,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3021,20 +2778,6 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
@@ -3067,16 +2810,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -3095,6 +2828,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
@@ -3146,17 +2885,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -3610,3 +3338,18 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
+8
-9
@@ -27,24 +27,23 @@ mime_guess = "2.0.5"
|
||||
uuid = { version = "1.20.0", features = ["v4", "serde"] }
|
||||
async-trait = "0.1.89"
|
||||
thiserror = "2.0.18"
|
||||
reqwest = { version = "0.12.18", features = ["json", "multipart"] }
|
||||
mockall = { version = "0.14.0", optional = true }
|
||||
rand = "0.9.1"
|
||||
pin-project-lite = "0.2.16"
|
||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls", "chrono", "uuid", "json"] }
|
||||
anyhow = "1.0.100"
|
||||
jsonwebtoken = "9.3.1"
|
||||
argon2 = "0.5.3"
|
||||
rand_core = { version = "0.6.4", features = ["std"] }
|
||||
time = "0.3.46"
|
||||
axum-server = "0.7.2"
|
||||
hyper = { version = "1.8.1", features = ["full"] }
|
||||
url = "2.5.8"
|
||||
quick-xml = "0.39.0"
|
||||
http-body-util = "0.1.3"
|
||||
openssl = { version = "0.10.75", features = ["vendored"] }
|
||||
icalendar = "0.17.6"
|
||||
dotenv = "0.15.0"
|
||||
lru = "0.12"
|
||||
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"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ ENV DATABASE_URL="postgres://postgres:postgres@postgres/oxicloud"
|
||||
RUN cargo build --release
|
||||
|
||||
# Stage 3: Create minimal final image
|
||||
FROM alpine:3.21.3
|
||||
FROM alpine:3.23.3
|
||||
# Install only necessary runtime dependencies and update packages
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk add --no-cache libgcc ca-certificates libpq tzdata
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Builder – compile the application
|
||||
FROM rust:1.82-alpine AS builder
|
||||
FROM rust:1.93.0-alpine3.23 AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev postgresql-dev
|
||||
@@ -29,7 +29,7 @@ RUN cargo build --release && \
|
||||
strip target/release/oxicloud
|
||||
|
||||
# Stage 2: Runtime – only include what is necessary to run the app
|
||||
FROM alpine:3.21.3
|
||||
FROM alpine:3.23
|
||||
|
||||
# Install runtime dependencies and clean up cache
|
||||
RUN apk add --no-cache libgcc openssl ca-certificates tzdata && \
|
||||
|
||||
@@ -13,111 +13,114 @@
|
||||
|
||||
</div>
|
||||
|
||||
## A lightweight, Rust-powered alternative to NextCloud
|
||||
## A fast, simple alternative to NextCloud
|
||||
|
||||
I built OxiCloud because I wanted a simpler, faster file storage solution than existing options. After struggling with NextCloud's performance on my home server, I decided to create something that prioritizes speed and simplicity while still being robust enough for daily use.
|
||||
NextCloud was too slow on my home server. So I built OxiCloud: a file storage system written in Rust that runs on minimal hardware and stays out of your way.
|
||||
|
||||

|
||||
|
||||
*OxiCloud's straightforward interface for file and folder management*
|
||||
## Why OxiCloud?
|
||||
|
||||
## ✨ What makes OxiCloud different?
|
||||
| Feature | What you get |
|
||||
|---------|--------------|
|
||||
| **Low resources** | Runs on 512MB RAM. No PHP, no bloat. |
|
||||
| **Fast** | Rust with LTO optimization. Sub-second responses. |
|
||||
| **Clean UI** | Works on desktop and mobile. No clutter. |
|
||||
| **Easy setup** | One binary, one database, done. |
|
||||
| **Multi-language** | English and Spanish out of the box. |
|
||||
|
||||
- **Lightweight**: Minimal resource requirements compared to PHP-based alternatives
|
||||
- **Responsive UI**: Clean, fast interface that works well on both desktop and mobile
|
||||
- **Rust Performance**: Built with Rust for memory safety and speed
|
||||
- **Optimized Binary**: Uses Link Time Optimization (LTO) for maximum performance
|
||||
- **Simple Setup**: Get running with minimal configuration
|
||||
- **Multilingual**: Full support for English and Spanish interfaces
|
||||
## Quick Start
|
||||
|
||||
## 🛠️ Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Rust 1.70+ and Cargo
|
||||
- PostgreSQL 13+ database
|
||||
- 512MB RAM minimum (1GB+ recommended)
|
||||
|
||||
### Installation
|
||||
You need Rust 1.70+, Cargo, and PostgreSQL 13+.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/DioCrafts/oxicloud.git
|
||||
cd oxicloud
|
||||
|
||||
# Configure your database (create .env file with your PostgreSQL connection)
|
||||
# Set up your database connection
|
||||
echo "DATABASE_URL=postgres://username:password@localhost/oxicloud" > .env
|
||||
|
||||
# Build the project
|
||||
# Build and run
|
||||
cargo build --release
|
||||
|
||||
# Run database migrations
|
||||
cargo run --bin migrate --features migrations
|
||||
|
||||
# Run the server
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost:8085`
|
||||
Open `http://localhost:8085` in your browser.
|
||||
|
||||
## 🧩 Technical Implementation
|
||||
|
||||
OxiCloud follows Clean Architecture principles with clear separation of concerns:
|
||||
|
||||
- **Domain Layer**: Core business logic and entities
|
||||
- **Application Layer**: Use cases and application services
|
||||
- **Infrastructure Layer**: External systems and implementations
|
||||
- **Interfaces Layer**: API and web controllers
|
||||
|
||||
The architecture makes it easy to extend functionality or swap components without affecting the core system.
|
||||
|
||||
## 🚧 Development
|
||||
### Docker (alternative)
|
||||
|
||||
```bash
|
||||
# Core development workflow
|
||||
cargo build # Build the project
|
||||
cargo run # Run the project locally
|
||||
cargo check # Quick check for compilation errors
|
||||
|
||||
# Optimized builds
|
||||
cargo build --release # Build with full optimization (LTO enabled)
|
||||
cargo run --release # Run optimized build
|
||||
|
||||
# Testing
|
||||
cargo test # Run all tests
|
||||
cargo test <test_name> # Run a specific test
|
||||
cargo bench # Run benchmarks with optimized settings
|
||||
|
||||
# Code quality
|
||||
cargo clippy # Run linter
|
||||
cargo fmt # Format code
|
||||
|
||||
# Debugging
|
||||
RUST_LOG=debug cargo run # Run with detailed logging
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🗺️ Roadmap
|
||||
That's it. The app runs on port 8086.
|
||||
|
||||
I'm actively working on improving OxiCloud with features that I need personally:
|
||||
## Architecture
|
||||
|
||||
- User authentication and multi-user support (in progress)
|
||||
- File sharing with simple links
|
||||
- WebDAV support for desktop integration
|
||||
- Basic file versioning
|
||||
- Simple mobile-friendly web interface enhancements
|
||||
- Trash bin functionality (in progress)
|
||||
OxiCloud uses Clean Architecture with four layers:
|
||||
|
||||
See [TODO-LIST.md](TODO-LIST.md) for my current development priorities.
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Interfaces │ API routes, handlers │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Application │ Use cases, services │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Domain │ Business logic │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Infrastructure│ Database, filesystem │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
Each layer only talks to the one below it. You can swap out the database or add new API endpoints without touching business logic.
|
||||
|
||||
Contributions are welcome! The project is still in early stages, so there's lots of room for improvement.
|
||||
## Development
|
||||
|
||||
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for detailed information on how to contribute to OxiCloud. All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
```bash
|
||||
cargo build # Build
|
||||
cargo run # Run locally
|
||||
cargo test # Run tests
|
||||
cargo clippy # Lint
|
||||
cargo fmt # Format
|
||||
|
||||
## 📜 License
|
||||
# For debugging
|
||||
RUST_LOG=debug cargo run
|
||||
```
|
||||
|
||||
OxiCloud is available under the [MIT License](LICENSE). See the [LICENSE](LICENSE) file for more information.
|
||||
## Current Features
|
||||
|
||||
- File upload, download, and organization
|
||||
- Folder management with drag-and-drop
|
||||
- Trash bin with restore functionality
|
||||
- User authentication with JWT
|
||||
- Personal folders per user
|
||||
- File deduplication
|
||||
- Write-behind cache for fast uploads
|
||||
- Search across files and folders
|
||||
- Favorites and recent files
|
||||
- Responsive grid/list views
|
||||
|
||||
## What's Next
|
||||
|
||||
I'm working on these when I have time:
|
||||
|
||||
- File sharing via links
|
||||
- WebDAV for desktop sync
|
||||
- Basic versioning
|
||||
- Mobile app improvements
|
||||
|
||||
Check [TODO-LIST.md](TODO-LIST.md) for the full list.
|
||||
|
||||
## Contributing
|
||||
|
||||
The project is early stage. There's plenty to improve.
|
||||
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a PR. Follow the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
Built by a developer who just wanted better file storage. Feedback and contributions welcome!
|
||||
Questions? Open an issue. Want to help? PRs welcome.
|
||||
|
||||
+2
-33
@@ -119,39 +119,8 @@ CREATE INDEX IF NOT EXISTS idx_user_recent_user_accessed ON auth.user_recent_fil
|
||||
|
||||
COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization';
|
||||
|
||||
-- Create admin user (password: Admin123!)
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'admin',
|
||||
'admin@oxicloud.local',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$H3VxE8LL2qPT31DM3loTg6D+O4MSc2sD7GjlQ5h7Jkw', -- Admin123!
|
||||
'admin',
|
||||
107374182400 -- 100GB for admin
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Create test user (password: test123)
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes
|
||||
) VALUES (
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'test',
|
||||
'test@oxicloud.local',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$ZG17Z7SFKhs9zWYbuk08CkHpyiznnZapYnxN5Vi62R4', -- test123
|
||||
'user',
|
||||
10737418240 -- 10GB for test user
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
-- NOTE: No default users are created. The first user to register through
|
||||
-- the admin setup wizard will become the administrator.
|
||||
|
||||
COMMENT ON TABLE auth.users IS 'Stores user account information';
|
||||
COMMENT ON TABLE auth.sessions IS 'Stores user session information for refresh tokens';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
-- OxiCloud Authentication Database Schema Migration
|
||||
-- Migration 001: Initial Schema
|
||||
|
||||
-- Create schema for auth-related tables
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Create UserRole enum type
|
||||
DO $BODY$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'userrole' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
CREATE TYPE auth.userrole AS ENUM ('admin', 'user');
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role auth.userrole NOT NULL,
|
||||
storage_quota_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 10GB default
|
||||
storage_used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Create indexes for users table
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON auth.users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email);
|
||||
|
||||
-- Sessions table for refresh tokens
|
||||
CREATE TABLE IF NOT EXISTS auth.sessions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
refresh_token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
ip_address TEXT, -- to support IPv6
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Create indexes for sessions table
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON auth.sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token ON auth.sessions(refresh_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON auth.sessions(expires_at);
|
||||
|
||||
-- Create function for active sessions to use in index
|
||||
CREATE OR REPLACE FUNCTION auth.is_session_active(expires_at timestamptz)
|
||||
RETURNS boolean AS $$
|
||||
BEGIN
|
||||
RETURN expires_at > now();
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Create index for active sessions with IMMUTABLE function
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked)
|
||||
WHERE NOT revoked AND auth.is_session_active(expires_at);
|
||||
|
||||
-- File ownership tracking
|
||||
CREATE TABLE IF NOT EXISTS auth.user_files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, file_path)
|
||||
);
|
||||
|
||||
-- Create indexes for user_files
|
||||
CREATE INDEX IF NOT EXISTS idx_user_files_user_id ON auth.user_files(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_files_file_id ON auth.user_files(file_id);
|
||||
|
||||
-- User favorites table for cross-device synchronization
|
||||
CREATE TABLE IF NOT EXISTS auth.user_favorites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL, -- 'file' or 'folder'
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON auth.user_favorites(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_item_id ON auth.user_favorites(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_type ON auth.user_favorites(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_created ON auth.user_favorites(created_at);
|
||||
|
||||
-- Combined index for quick lookups by user and type
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_type ON auth.user_favorites(user_id, item_type);
|
||||
|
||||
-- Table for recent files
|
||||
CREATE TABLE IF NOT EXISTS auth.user_recent_files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL, -- 'file' or 'folder'
|
||||
accessed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recent_user_id ON auth.user_recent_files(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recent_item_id ON auth.user_recent_files(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recent_type ON auth.user_recent_files(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recent_accessed ON auth.user_recent_files(accessed_at);
|
||||
|
||||
-- Combined index for quick lookups by user and accessed time (for sorting)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recent_user_accessed ON auth.user_recent_files(user_id, accessed_at DESC);
|
||||
|
||||
COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization';
|
||||
COMMENT ON TABLE auth.users IS 'Stores user account information';
|
||||
COMMENT ON TABLE auth.sessions IS 'Stores user session information for refresh tokens';
|
||||
COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilization by users';
|
||||
COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization';
|
||||
@@ -1,49 +0,0 @@
|
||||
-- Migration 002: Default Users
|
||||
|
||||
-- Check if admin user already exists before creating it
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE username = 'admin') THEN
|
||||
-- Create admin user (password: Admin123!)
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'admin',
|
||||
'admin@oxicloud.local',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$H3VxE8LL2qPT31DM3loTg6D+O4MSc2sD7GjlQ5h7Jkw', -- Admin123!
|
||||
'admin',
|
||||
107374182400 -- 100GB for admin
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Check if test user already exists before creating it
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE username = 'test') THEN
|
||||
-- Create test user (password: test123)
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes
|
||||
) VALUES (
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'test',
|
||||
'test@oxicloud.local',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$ZG17Z7SFKhs9zWYbuk08CkHpyiznnZapYnxN5Vi62R4', -- test123
|
||||
'user',
|
||||
10737418240 -- 10GB for test user
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -1,72 +0,0 @@
|
||||
-- OxiCloud CalDAV Schema Migration
|
||||
-- Migration 003: CalDAV Schema
|
||||
|
||||
-- Create schema for CalDAV-related tables
|
||||
CREATE SCHEMA IF NOT EXISTS caldav;
|
||||
|
||||
-- Calendar table
|
||||
CREATE TABLE IF NOT EXISTS caldav.calendars (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
description TEXT,
|
||||
color VARCHAR(50),
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner_id, name)
|
||||
);
|
||||
|
||||
-- Calendar properties for custom properties (for extended CalDAV support)
|
||||
CREATE TABLE IF NOT EXISTS caldav.calendar_properties (
|
||||
id SERIAL PRIMARY KEY,
|
||||
calendar_id UUID NOT NULL REFERENCES caldav.calendars(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(calendar_id, name)
|
||||
);
|
||||
|
||||
-- Calendar events table
|
||||
CREATE TABLE IF NOT EXISTS caldav.calendar_events (
|
||||
id UUID PRIMARY KEY,
|
||||
calendar_id UUID NOT NULL REFERENCES caldav.calendars(id) ON DELETE CASCADE,
|
||||
summary VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
rrule TEXT, -- Recurrence rule
|
||||
ical_uid VARCHAR(255) NOT NULL, -- UID from iCalendar format
|
||||
ical_data TEXT NOT NULL, -- Complete iCalendar data
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(calendar_id, ical_uid)
|
||||
);
|
||||
|
||||
-- Calendar sharing table
|
||||
CREATE TABLE IF NOT EXISTS caldav.calendar_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
calendar_id UUID NOT NULL REFERENCES caldav.calendars(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
access_level VARCHAR(50) NOT NULL, -- 'read', 'write', 'owner'
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(calendar_id, user_id)
|
||||
);
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_owner ON caldav.calendars(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_public ON caldav.calendars(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_properties_calendar ON caldav.calendar_properties(calendar_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_event_calendar ON caldav.calendar_events(calendar_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_event_time_range ON caldav.calendar_events(start_time, end_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_event_uid ON caldav.calendar_events(ical_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_shares_calendar ON caldav.calendar_shares(calendar_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_shares_user ON caldav.calendar_shares(user_id);
|
||||
|
||||
COMMENT ON TABLE caldav.calendars IS 'Stores calendar information for CalDAV support';
|
||||
COMMENT ON TABLE caldav.calendar_properties IS 'Stores custom properties for calendars';
|
||||
COMMENT ON TABLE caldav.calendar_events IS 'Stores calendar events with iCalendar data';
|
||||
COMMENT ON TABLE caldav.calendar_shares IS 'Tracks calendar sharing between users';
|
||||
@@ -1,72 +0,0 @@
|
||||
-- Create the carddav schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS carddav;
|
||||
|
||||
-- Address books table
|
||||
CREATE TABLE IF NOT EXISTS carddav.address_books (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
description TEXT,
|
||||
color VARCHAR(50),
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner_id, name)
|
||||
);
|
||||
|
||||
-- Address book sharing
|
||||
CREATE TABLE IF NOT EXISTS carddav.address_book_shares (
|
||||
address_book_id UUID NOT NULL REFERENCES carddav.address_books(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
can_write BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY(address_book_id, user_id)
|
||||
);
|
||||
|
||||
-- Contacts table
|
||||
CREATE TABLE IF NOT EXISTS carddav.contacts (
|
||||
id UUID PRIMARY KEY,
|
||||
address_book_id UUID NOT NULL REFERENCES carddav.address_books(id) ON DELETE CASCADE,
|
||||
uid VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255),
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
nickname VARCHAR(255),
|
||||
email JSONB,
|
||||
phone JSONB,
|
||||
address JSONB,
|
||||
organization VARCHAR(255),
|
||||
title VARCHAR(255),
|
||||
notes TEXT,
|
||||
photo_url TEXT,
|
||||
birthday DATE,
|
||||
anniversary DATE,
|
||||
vcard TEXT NOT NULL,
|
||||
etag VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(address_book_id, uid)
|
||||
);
|
||||
|
||||
-- Contact groups
|
||||
CREATE TABLE IF NOT EXISTS carddav.contact_groups (
|
||||
id UUID PRIMARY KEY,
|
||||
address_book_id UUID NOT NULL REFERENCES carddav.address_books(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Group memberships
|
||||
CREATE TABLE IF NOT EXISTS carddav.group_memberships (
|
||||
group_id UUID NOT NULL REFERENCES carddav.contact_groups(id) ON DELETE CASCADE,
|
||||
contact_id UUID NOT NULL REFERENCES carddav.contacts(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(group_id, contact_id)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_address_book_id ON carddav.contacts(address_book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_uid ON carddav.contacts(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_updated_at ON carddav.contacts(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_address_books_owner_id ON carddav.address_books(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_memberships_group_id ON carddav.group_memberships(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_memberships_contact_id ON carddav.group_memberships(contact_id);
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Script to safely reset the admin user in OxiCloud
|
||||
-- Run this script to delete the existing admin user if you're having issues creating one
|
||||
|
||||
-- Set the correct schema
|
||||
SET search_path TO auth;
|
||||
|
||||
-- Delete the admin user if it exists
|
||||
DELETE FROM auth.users WHERE username = 'admin';
|
||||
|
||||
-- Check if the user was deleted
|
||||
SELECT 'Admin user has been removed successfully. You can now create a new admin user.' AS message
|
||||
WHERE NOT EXISTS (SELECT 1 FROM auth.users WHERE username = 'admin');
|
||||
|
||||
-- Check if there are still users in the system
|
||||
SELECT 'Warning: No users remain in the system. You should register a new admin user.' AS warning
|
||||
WHERE NOT EXISTS (SELECT 1 FROM auth.users LIMIT 1);
|
||||
|
||||
-- Output remaining users for verification
|
||||
SELECT username, email, role FROM auth.users ORDER BY role, username;
|
||||
@@ -36,6 +36,16 @@ pub trait FileStoragePort: Send + Sync + 'static {
|
||||
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>;
|
||||
|
||||
@@ -51,6 +61,17 @@ pub trait FileStoragePort: Send + Sync + 'static {
|
||||
/// 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>;
|
||||
|
||||
@@ -62,6 +83,24 @@ pub trait FileStoragePort: Send + Sync + 'static {
|
||||
|
||||
/// 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 secundario para persistencia de carpetas
|
||||
|
||||
@@ -191,6 +191,46 @@ impl FileService {
|
||||
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
|
||||
@@ -293,6 +333,32 @@ impl FileService {
|
||||
.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);
|
||||
|
||||
@@ -38,6 +38,18 @@ impl FileRepository for DomainFileRepoAdapter {
|
||||
.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,
|
||||
@@ -83,6 +95,23 @@ impl FileRepository for DomainFileRepoAdapter {
|
||||
.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
|
||||
|
||||
+136
-1
@@ -16,6 +16,7 @@ use crate::infrastructure::services::id_mapping_service::IdMappingService;
|
||||
use crate::infrastructure::services::id_mapping_optimizer::IdMappingOptimizer;
|
||||
use crate::infrastructure::services::cache_manager::StorageCacheManager;
|
||||
use crate::infrastructure::services::file_metadata_cache::FileMetadataCache;
|
||||
use crate::infrastructure::services::file_content_cache::{FileContentCache, FileContentCacheConfig, SharedFileContentCache};
|
||||
use crate::infrastructure::services::buffer_pool::BufferPool;
|
||||
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
@@ -97,6 +98,14 @@ impl AppServiceFactory {
|
||||
StorageCacheManager::start_cleanup_task(cache_manager_clone).await;
|
||||
});
|
||||
|
||||
// File content cache for ultra-fast file serving (hot files in RAM)
|
||||
let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig {
|
||||
max_file_size: 10 * 1024 * 1024, // 10MB max per file
|
||||
max_total_size: 512 * 1024 * 1024, // 512MB total cache
|
||||
max_entries: 10000, // Up to 10k files
|
||||
}));
|
||||
tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries");
|
||||
|
||||
// ID mapping service para carpetas
|
||||
let folder_id_mapping_path = self.storage_path.join("folder_ids.json");
|
||||
let folder_id_mapping_service = Arc::new(
|
||||
@@ -117,14 +126,56 @@ impl AppServiceFactory {
|
||||
// Iniciar tarea de limpieza del optimizer
|
||||
IdMappingOptimizer::start_cleanup_task(id_mapping_optimizer.clone());
|
||||
|
||||
tracing::info!("Core services initialized: path service, cache manager, ID mapping");
|
||||
// Thumbnail service para generación de miniaturas
|
||||
let thumbnail_service = Arc::new(
|
||||
crate::infrastructure::services::thumbnail_service::ThumbnailService::new(
|
||||
&self.storage_path,
|
||||
5000, // max 5000 thumbnails en cache
|
||||
100 * 1024 * 1024, // max 100MB de cache
|
||||
)
|
||||
);
|
||||
// Inicializar directorios de thumbnails
|
||||
thumbnail_service.initialize().await?;
|
||||
|
||||
// Write-behind cache para uploads instantáneos de archivos pequeños
|
||||
let write_behind_cache = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new();
|
||||
|
||||
// Chunked upload service para archivos grandes (>10MB)
|
||||
let chunked_temp_dir = std::path::PathBuf::from(&self.storage_path).join(".uploads");
|
||||
let chunked_upload_service = Arc::new(
|
||||
crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new(chunked_temp_dir)
|
||||
);
|
||||
|
||||
// Image transcoding service para conversión automática a WebP
|
||||
let image_transcode_service = Arc::new(
|
||||
crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new(
|
||||
&self.storage_path,
|
||||
2000, // max 2000 imágenes transcodificadas en cache
|
||||
50 * 1024 * 1024, // max 50MB de cache en memoria
|
||||
)
|
||||
);
|
||||
image_transcode_service.initialize().await?;
|
||||
|
||||
// Deduplication service para eliminar archivos duplicados
|
||||
let dedup_service = Arc::new(
|
||||
crate::infrastructure::services::dedup_service::DedupService::new(&self.storage_path)
|
||||
);
|
||||
dedup_service.initialize().await?;
|
||||
|
||||
tracing::info!("Core services initialized: path service, cache manager, file content cache, ID mapping, thumbnails, write-behind cache, chunked upload, image transcode, dedup");
|
||||
|
||||
Ok(CoreServices {
|
||||
path_service,
|
||||
cache_manager,
|
||||
file_content_cache,
|
||||
id_mapping_service: folder_id_mapping_service,
|
||||
file_id_mapping_service,
|
||||
id_mapping_optimizer,
|
||||
thumbnail_service,
|
||||
write_behind_cache,
|
||||
chunked_upload_service,
|
||||
image_transcode_service,
|
||||
dedup_service,
|
||||
config: self.config.clone(),
|
||||
})
|
||||
}
|
||||
@@ -419,9 +470,15 @@ impl AppServiceFactory {
|
||||
pub struct CoreServices {
|
||||
pub path_service: Arc<PathService>,
|
||||
pub cache_manager: Arc<StorageCacheManager>,
|
||||
pub file_content_cache: SharedFileContentCache,
|
||||
pub id_mapping_service: Arc<dyn crate::application::ports::outbound::IdMappingPort>,
|
||||
pub file_id_mapping_service: Arc<IdMappingService>,
|
||||
pub id_mapping_optimizer: Arc<IdMappingOptimizer>,
|
||||
pub thumbnail_service: Arc<crate::infrastructure::services::thumbnail_service::ThumbnailService>,
|
||||
pub write_behind_cache: Arc<crate::infrastructure::services::write_behind_cache::WriteBehindCache>,
|
||||
pub chunked_upload_service: Arc<crate::infrastructure::services::chunked_upload_service::ChunkedUploadService>,
|
||||
pub image_transcode_service: Arc<crate::infrastructure::services::image_transcode_service::ImageTranscodeService>,
|
||||
pub dedup_service: Arc<crate::infrastructure::services::dedup_service::DedupService>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
@@ -634,6 +691,16 @@ impl Default for AppState {
|
||||
Ok(crate::domain::entities::file::File::default())
|
||||
}
|
||||
|
||||
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>>,
|
||||
) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
|
||||
Ok(crate::domain::entities::file::File::default())
|
||||
}
|
||||
|
||||
async fn get_file(&self, _id: &str) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
|
||||
Ok(crate::domain::entities::file::File::default())
|
||||
}
|
||||
@@ -655,6 +722,20 @@ impl Default for AppState {
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
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>, crate::common::errors::DomainError> {
|
||||
let empty_stream = futures::stream::empty::<Result<bytes::Bytes, std::io::Error>>();
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, _id: &str) -> Result<bytes::Bytes, crate::common::errors::DomainError> {
|
||||
Ok(bytes::Bytes::new())
|
||||
}
|
||||
|
||||
async fn move_file(&self, _file_id: &str, _target_folder_id: Option<String>) -> Result<crate::domain::entities::file::File, crate::common::errors::DomainError> {
|
||||
Ok(crate::domain::entities::file::File::default())
|
||||
}
|
||||
@@ -670,6 +751,16 @@ impl Default for AppState {
|
||||
async fn update_file_content(&self, _file_id: &str, _content: Vec<u8>) -> Result<(), crate::common::errors::DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_size: u64,
|
||||
) -> Result<(crate::domain::entities::file::File, std::path::PathBuf), crate::common::errors::DomainError> {
|
||||
Ok((crate::domain::entities::file::File::default(), std::path::PathBuf::from("/tmp/dummy")))
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyFolderStoragePort;
|
||||
@@ -931,13 +1022,57 @@ impl Default for AppState {
|
||||
let dummy_file_id_mapping = Arc::new(IdMappingService::dummy());
|
||||
let dummy_id_optimizer = Arc::new(IdMappingOptimizer::new(dummy_file_id_mapping.clone()));
|
||||
|
||||
// Create file content cache for stub
|
||||
let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig::default()));
|
||||
|
||||
// Create dummy thumbnail service
|
||||
let dummy_thumbnail_service = Arc::new(
|
||||
crate::infrastructure::services::thumbnail_service::ThumbnailService::new(
|
||||
&std::path::PathBuf::from("./storage"),
|
||||
100,
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
);
|
||||
|
||||
// Create dummy write-behind cache
|
||||
let dummy_write_behind_cache = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new();
|
||||
|
||||
// Create dummy chunked upload service
|
||||
let dummy_chunked_upload_service = Arc::new(
|
||||
crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new(
|
||||
std::path::PathBuf::from("./storage/.uploads")
|
||||
)
|
||||
);
|
||||
|
||||
// Create dummy image transcode service
|
||||
let dummy_image_transcode_service = Arc::new(
|
||||
crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new(
|
||||
&std::path::PathBuf::from("./storage"),
|
||||
100,
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
);
|
||||
|
||||
// Create dummy dedup service
|
||||
let dummy_dedup_service = Arc::new(
|
||||
crate::infrastructure::services::dedup_service::DedupService::new(
|
||||
&std::path::PathBuf::from("./storage")
|
||||
)
|
||||
);
|
||||
|
||||
// This creates the core services needed for basic functionality
|
||||
let core_services = CoreServices {
|
||||
path_service: path_service.clone(),
|
||||
cache_manager: Arc::new(crate::infrastructure::services::cache_manager::StorageCacheManager::default()),
|
||||
file_content_cache,
|
||||
id_mapping_service: id_mapping_service.clone(),
|
||||
file_id_mapping_service: dummy_file_id_mapping,
|
||||
id_mapping_optimizer: dummy_id_optimizer,
|
||||
thumbnail_service: dummy_thumbnail_service,
|
||||
write_behind_cache: dummy_write_behind_cache,
|
||||
chunked_upload_service: dummy_chunked_upload_service,
|
||||
image_transcode_service: dummy_image_transcode_service,
|
||||
dedup_service: dummy_dedup_service,
|
||||
config: config.clone(),
|
||||
};
|
||||
|
||||
|
||||
@@ -95,6 +95,32 @@ pub trait FileRepository: Send + Sync + 'static {
|
||||
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
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -174,6 +200,41 @@ pub trait FileRepository: Send + Sync + 'static {
|
||||
#[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.
|
||||
*
|
||||
|
||||
@@ -420,6 +420,18 @@ impl FileStoragePort for FileFsRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to save file: {}", e)))
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError> {
|
||||
FileRepository::save_file_from_stream(self, name, folder_id, content_type, stream)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to save file from stream: {}", e)))
|
||||
}
|
||||
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
self.get_file_by_id(id)
|
||||
.await
|
||||
@@ -450,6 +462,23 @@ impl FileStoragePort for FileFsRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get stream for file with ID: {}: {}", id, e)))
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
FileRepository::get_file_range_stream(self, id, start, end)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to get range stream for file with ID: {}: {}", id, e)))
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
||||
FileRepository::get_file_mmap(self, id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage", format!("Failed to mmap file with ID: {}: {}", id, e)))
|
||||
}
|
||||
|
||||
async fn move_file(&self, file_id: &str, target_folder_id: Option<String>) -> Result<File, DomainError> {
|
||||
// Clone target_folder_id before passing to avoid ownership issues
|
||||
let cloned_target = target_folder_id.clone();
|
||||
@@ -548,6 +577,107 @@ impl FileStoragePort for FileFsRepository {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Registra metadatos de archivo SIN escribir contenido (write-behind puro)
|
||||
///
|
||||
/// Este método es ultrarrápido (~0.1ms) porque:
|
||||
/// 1. NO escribe a disco
|
||||
/// 2. Solo genera ID y registra mappings
|
||||
/// 3. Devuelve la ruta donde DEBE escribirse el contenido
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
size: u64,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use mime_guess::from_path;
|
||||
|
||||
// Get the folder path from the mediator
|
||||
let folder_path = match &folder_id {
|
||||
Some(id) => {
|
||||
match self.storage_mediator.get_folder_path(id).await {
|
||||
Ok(path) => {
|
||||
let lossy = path.to_string_lossy().to_string();
|
||||
let folder_name = path.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or_else(|| &lossy);
|
||||
StoragePath::from_string(folder_name)
|
||||
},
|
||||
Err(_) => StoragePath::root(),
|
||||
}
|
||||
},
|
||||
None => StoragePath::root(),
|
||||
};
|
||||
|
||||
// Create the storage path for the file
|
||||
let mut file_storage_path = folder_path.join(&name);
|
||||
let mut original_name = name.clone();
|
||||
|
||||
// Check for duplicates and generate unique name
|
||||
let mut counter = 1;
|
||||
while self.file_exists_at_storage_path(&file_storage_path).await.unwrap_or(false) {
|
||||
let (stem, ext) = if let Some(dot_pos) = original_name.rfind('.') {
|
||||
(original_name[..dot_pos].to_string(), original_name[dot_pos..].to_string())
|
||||
} else {
|
||||
(original_name.clone(), String::new())
|
||||
};
|
||||
let new_name = format!("{}_{}{}", stem, counter, ext);
|
||||
file_storage_path = folder_path.join(&new_name);
|
||||
original_name = new_name;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
// Resolve absolute path (this is where content will be written)
|
||||
let abs_path = self.resolve_storage_path(&file_storage_path);
|
||||
|
||||
// Ensure parent directory exists
|
||||
self.ensure_parent_directory(&abs_path).await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage",
|
||||
format!("Failed to create parent directory: {}", e)))?;
|
||||
|
||||
// Determine MIME type
|
||||
let mime_type = if content_type.is_empty() {
|
||||
from_path(&abs_path).first_or_octet_stream().to_string()
|
||||
} else {
|
||||
content_type
|
||||
};
|
||||
|
||||
// Get current timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Generate unique ID and register mapping
|
||||
let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage",
|
||||
format!("Failed to generate file ID: {}", e)))?;
|
||||
|
||||
// Save ID mapping
|
||||
self.id_mapping_service.save_changes().await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage",
|
||||
format!("Failed to save ID mapping: {}", e)))?;
|
||||
|
||||
// Create File entity (with provided size, timestamps are "now")
|
||||
let file = self.create_file_entity(
|
||||
id.clone(),
|
||||
original_name,
|
||||
file_storage_path,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
Some(now),
|
||||
Some(now),
|
||||
).await
|
||||
.map_err(|e| DomainError::internal_error("FileStorage",
|
||||
format!("Failed to create file entity: {}", e)))?;
|
||||
|
||||
tracing::debug!("⚡ Registered deferred file: {} -> {:?}", id, abs_path);
|
||||
|
||||
Ok((file, abs_path))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -921,6 +1051,192 @@ impl FileRepository for FileFsRepository {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Streaming upload - writes chunks directly to disk as they arrive
|
||||
///
|
||||
/// This is the most efficient method for large file uploads:
|
||||
/// - Constant ~10MB memory usage regardless of file size
|
||||
/// - Chunks are written to disk immediately
|
||||
/// - Uses atomic rename for crash safety
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
mut stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> FileRepositoryResult<File> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use futures::StreamExt;
|
||||
|
||||
// Get the folder path from the mediator
|
||||
let folder_path = match &folder_id {
|
||||
Some(id) => {
|
||||
match self.storage_mediator.get_folder_path(id).await {
|
||||
Ok(path) => {
|
||||
let lossy = path.to_string_lossy().to_string();
|
||||
let folder_name = path.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or_else(|| &lossy);
|
||||
StoragePath::from_string(folder_name)
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Error getting folder: {}", e);
|
||||
StoragePath::root()
|
||||
},
|
||||
}
|
||||
},
|
||||
None => StoragePath::root(),
|
||||
};
|
||||
|
||||
// Create the storage path for the file
|
||||
let mut file_storage_path = folder_path.join(&name);
|
||||
|
||||
// Check if file already exists and generate a unique name if needed
|
||||
let mut exists = self.file_exists_at_storage_path(&file_storage_path).await?;
|
||||
let mut original_name = name.clone();
|
||||
let mut counter = 1;
|
||||
|
||||
while exists {
|
||||
let file_stem;
|
||||
let extension;
|
||||
|
||||
if let Some(dot_pos) = original_name.rfind('.') {
|
||||
file_stem = original_name[..dot_pos].to_string();
|
||||
extension = original_name[dot_pos..].to_string();
|
||||
} else {
|
||||
file_stem = original_name.clone();
|
||||
extension = "".to_string();
|
||||
}
|
||||
|
||||
let new_name = format!("{}_{}{}", file_stem, counter, extension);
|
||||
let new_file_storage_path = folder_path.join(&new_name);
|
||||
exists = self.file_exists_at_storage_path(&new_file_storage_path).await?;
|
||||
|
||||
if !exists {
|
||||
tracing::info!("Generated unique name: {} -> {}", original_name, new_name);
|
||||
original_name = new_name.clone();
|
||||
file_storage_path = new_file_storage_path;
|
||||
} else {
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
let abs_path = self.resolve_storage_path(&file_storage_path);
|
||||
self.ensure_parent_directory(&abs_path).await?;
|
||||
|
||||
// Create a temporary file for atomic write
|
||||
let temp_path = abs_path.with_extension("tmp.upload");
|
||||
|
||||
tracing::info!("📥 STREAMING UPLOAD: {} -> {}", original_name, abs_path.display());
|
||||
|
||||
// Create the temp file
|
||||
let mut file = time::timeout(
|
||||
self.config.timeouts.file_timeout(),
|
||||
TokioFile::create(&temp_path)
|
||||
).await
|
||||
.map_err(|_| FileRepositoryError::Timeout(format!("Timeout creating temp file: {}", temp_path.display())))?
|
||||
.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
// Stream chunks directly to disk
|
||||
let mut total_bytes: u64 = 0;
|
||||
let mut chunk_count = 0u32;
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(FileRepositoryError::IoError)?;
|
||||
let chunk_len = chunk.len();
|
||||
|
||||
// Write chunk directly to disk - no memory accumulation
|
||||
file.write_all(&chunk).await.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
total_bytes += chunk_len as u64;
|
||||
chunk_count += 1;
|
||||
|
||||
// Log progress every 10MB
|
||||
if total_bytes > 0 && total_bytes % (10 * 1024 * 1024) < chunk_len as u64 {
|
||||
tracing::debug!(
|
||||
"📥 Upload progress: {} - {}MB received ({} chunks)",
|
||||
original_name,
|
||||
total_bytes / (1024 * 1024),
|
||||
chunk_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush and sync to ensure data is on disk
|
||||
file.flush().await.map_err(FileRepositoryError::IoError)?;
|
||||
file.sync_all().await.map_err(FileRepositoryError::IoError)?;
|
||||
drop(file); // Close the file handle
|
||||
|
||||
// Atomic rename from temp to final path
|
||||
fs::rename(&temp_path, &abs_path).await.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
tracing::info!(
|
||||
"✅ STREAMING UPLOAD COMPLETE: {} ({} bytes, {} chunks)",
|
||||
original_name, total_bytes, chunk_count
|
||||
);
|
||||
|
||||
// Get file metadata from disk
|
||||
let (size, created_at, modified_at) = self.get_file_metadata(&abs_path).await?;
|
||||
|
||||
// Determine the MIME type
|
||||
let mime_type = if content_type.is_empty() {
|
||||
from_path(&abs_path).first_or_octet_stream().to_string()
|
||||
} else {
|
||||
content_type
|
||||
};
|
||||
|
||||
// Create and return the file entity with a persistent ID
|
||||
let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await?;
|
||||
let path_string = file_storage_path.to_string();
|
||||
|
||||
let file = self.create_file_entity(
|
||||
id.clone(),
|
||||
original_name,
|
||||
file_storage_path,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
Some(created_at),
|
||||
Some(modified_at),
|
||||
).await?;
|
||||
|
||||
// Persist ID mapping with verification
|
||||
for attempt in 1..=3 {
|
||||
match self.id_mapping_service.save_changes().await {
|
||||
Ok(_) => {
|
||||
if let Ok(verified_path) = self.id_mapping_service.get_path_by_id(&id).await {
|
||||
if verified_path.to_string() == path_string {
|
||||
tracing::debug!("ID mapping verified: {} -> {}", id, path_string);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if attempt == 3 {
|
||||
return Err(FileRepositoryError::Other(
|
||||
format!("Failed to verify ID mapping after 3 attempts")
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
},
|
||||
Err(e) if attempt < 3 => {
|
||||
tracing::warn!("ID mapping save failed (attempt {}): {}", attempt, e);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
},
|
||||
Err(e) => {
|
||||
return Err(FileRepositoryError::Other(
|
||||
format!("Failed to save ID mapping: {}", e)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate directory cache
|
||||
if let Some(parent_dir) = abs_path.parent() {
|
||||
self.metadata_cache.invalidate_directory(parent_dir).await;
|
||||
}
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
async fn save_file_with_id(
|
||||
&self,
|
||||
id: String,
|
||||
@@ -1511,6 +1827,119 @@ impl FileRepository for FileFsRepository {
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
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>> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
// Get the file first to check if it exists and get the path
|
||||
let file = self.get_file_by_id(id).await?;
|
||||
let abs_path = self.resolve_storage_path(file.storage_path());
|
||||
|
||||
// Get file metadata for size
|
||||
let metadata = time::timeout(
|
||||
self.config.timeouts.file_timeout(),
|
||||
fs::metadata(&abs_path)
|
||||
).await
|
||||
.map_err(|_| FileRepositoryError::Timeout(format!("Timeout getting metadata: {}", abs_path.display())))?
|
||||
.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
let file_size = metadata.len();
|
||||
|
||||
// Validate range
|
||||
if start >= file_size {
|
||||
return Err(FileRepositoryError::Other(
|
||||
format!("Range start {} is beyond file size {}", start, file_size)
|
||||
));
|
||||
}
|
||||
|
||||
// Calculate actual end position
|
||||
let actual_end = end.map(|e| e.min(file_size - 1)).unwrap_or(file_size - 1);
|
||||
let range_length = actual_end - start + 1;
|
||||
|
||||
// Open file and seek to start position
|
||||
let mut file_handle = time::timeout(
|
||||
self.config.timeouts.file_timeout(),
|
||||
TokioFile::open(&abs_path)
|
||||
).await
|
||||
.map_err(|_| FileRepositoryError::Timeout(format!("Timeout opening file: {}", abs_path.display())))?
|
||||
.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
// Seek to start position
|
||||
file_handle.seek(std::io::SeekFrom::Start(start)).await
|
||||
.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
// Calculate optimal chunk size
|
||||
let chunk_size = if range_length > 1024 * 1024 {
|
||||
self.config.resources.chunk_size_bytes
|
||||
} else {
|
||||
8192 // 8KB for smaller ranges
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Range streaming {} bytes={}-{} (chunk_size={})",
|
||||
abs_path.display(), start, actual_end, chunk_size
|
||||
);
|
||||
|
||||
// Create a limited reader that only reads up to range_length bytes
|
||||
let limited_reader = file_handle.take(range_length);
|
||||
|
||||
// Create stream with FramedRead
|
||||
let codec = BytesCodec::new();
|
||||
let stream = FramedRead::with_capacity(limited_reader, codec, chunk_size)
|
||||
.map(|result| {
|
||||
result.map(|bytes_mut| bytes_mut.freeze())
|
||||
});
|
||||
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
/// Memory-maps a file for zero-copy kernel access.
|
||||
///
|
||||
/// Uses mmap for files in the 10-100MB range where:
|
||||
/// - Full cache (RAM) would be wasteful
|
||||
/// - Streaming adds unnecessary overhead
|
||||
/// - The kernel's page cache provides optimal performance
|
||||
///
|
||||
/// This runs in spawn_blocking since mmap is synchronous.
|
||||
async fn get_file_mmap(&self, id: &str) -> FileRepositoryResult<Bytes> {
|
||||
use memmap2::Mmap;
|
||||
|
||||
// Get file info and path
|
||||
let file = self.get_file_by_id(id).await?;
|
||||
let abs_path = self.resolve_storage_path(file.storage_path());
|
||||
|
||||
tracing::info!(
|
||||
"🗺️ MMAP: Memory-mapping file {} ({} bytes)",
|
||||
file.name(), file.size()
|
||||
);
|
||||
|
||||
// Clone path for the blocking task
|
||||
let path_clone = abs_path.clone();
|
||||
|
||||
// mmap is synchronous, so we use spawn_blocking
|
||||
let result = task::spawn_blocking(move || -> Result<Bytes, FileRepositoryError> {
|
||||
// Open file with std::fs (blocking)
|
||||
let file_handle = std::fs::File::open(&path_clone)
|
||||
.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
// Create memory map (unsafe but well-tested)
|
||||
// SAFETY: The file is opened read-only and we don't modify it
|
||||
let mmap = unsafe { Mmap::map(&file_handle) }
|
||||
.map_err(FileRepositoryError::IoError)?;
|
||||
|
||||
// Convert to Bytes - this creates a reference to the mapped memory
|
||||
// The Bytes will keep the mmap alive until dropped
|
||||
Ok(Bytes::copy_from_slice(&mmap[..]))
|
||||
}).await
|
||||
.map_err(|e| FileRepositoryError::Other(format!("mmap task panicked: {}", e)))?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn move_file(&self, id: &str, target_folder_id: Option<String>) -> FileRepositoryResult<File> {
|
||||
// Get the original file
|
||||
let original_file = self.get_file_by_id(id).await?;
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
//! Chunked Upload Service - TUS-like Protocol for Large File Uploads
|
||||
//!
|
||||
//! Enables parallel chunk uploads for files >10MB with:
|
||||
//! - Resumable uploads (persist progress)
|
||||
//! - Parallel chunk transfers (up to 6 concurrent)
|
||||
//! - Automatic reassembly
|
||||
//! - Expiration cleanup (24h)
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. POST /api/uploads → Create upload session, get upload_id
|
||||
//! 2. PATCH /api/uploads/:id → Upload chunks (parallel OK)
|
||||
//! 3. HEAD /api/uploads/:id → Check progress
|
||||
//! 4. POST /api/uploads/:id/complete → Finalize and assemble
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::{self, File, OpenOptions};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Minimum file size to use chunked upload (10MB)
|
||||
pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Default chunk size (5MB) - optimized for parallel transfers
|
||||
pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Maximum concurrent chunks per upload
|
||||
pub const MAX_PARALLEL_CHUNKS: usize = 6;
|
||||
|
||||
/// Upload session expiration time
|
||||
const SESSION_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours
|
||||
|
||||
/// Chunk status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ChunkStatus {
|
||||
Pending,
|
||||
Uploading,
|
||||
Complete,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Individual chunk metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChunkInfo {
|
||||
pub index: usize,
|
||||
pub offset: u64,
|
||||
pub size: usize,
|
||||
pub status: ChunkStatus,
|
||||
pub checksum: Option<String>,
|
||||
}
|
||||
|
||||
/// Upload session state
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadSession {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub content_type: String,
|
||||
pub total_size: u64,
|
||||
pub chunk_size: usize,
|
||||
pub chunks: Vec<ChunkInfo>,
|
||||
pub created_at: Instant,
|
||||
pub last_activity: Instant,
|
||||
pub temp_dir: PathBuf,
|
||||
pub bytes_received: u64,
|
||||
}
|
||||
|
||||
impl UploadSession {
|
||||
/// Calculate number of chunks needed
|
||||
pub fn calculate_chunk_count(total_size: u64, chunk_size: usize) -> usize {
|
||||
((total_size as usize + chunk_size - 1) / chunk_size).max(1)
|
||||
}
|
||||
|
||||
/// Get upload progress (0.0 - 1.0)
|
||||
pub fn progress(&self) -> f64 {
|
||||
if self.total_size == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.bytes_received as f64 / self.total_size as f64
|
||||
}
|
||||
|
||||
/// Check if all chunks are complete
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.chunks.iter().all(|c| c.status == ChunkStatus::Complete)
|
||||
}
|
||||
|
||||
/// Get pending chunk indices
|
||||
pub fn pending_chunks(&self) -> Vec<usize> {
|
||||
self.chunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.status == ChunkStatus::Pending)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if session has expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.last_activity.elapsed() > SESSION_EXPIRATION
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for upload session creation
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CreateUploadResponse {
|
||||
pub upload_id: String,
|
||||
pub chunk_size: usize,
|
||||
pub total_chunks: usize,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Response for chunk upload
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ChunkUploadResponse {
|
||||
pub chunk_index: usize,
|
||||
pub bytes_received: u64,
|
||||
pub progress: f64,
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
/// Response for upload status
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UploadStatusResponse {
|
||||
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,
|
||||
}
|
||||
|
||||
/// Chunked Upload Service
|
||||
pub struct ChunkedUploadService {
|
||||
sessions: Arc<RwLock<HashMap<String, UploadSession>>>,
|
||||
temp_base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ChunkedUploadService {
|
||||
/// Create new service with temp directory for chunks
|
||||
pub fn new(temp_base_dir: PathBuf) -> Self {
|
||||
let service = Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
temp_base_dir,
|
||||
};
|
||||
|
||||
// Start cleanup task
|
||||
let sessions_clone = service.sessions.clone();
|
||||
let temp_dir_clone = service.temp_base_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
Self::cleanup_loop(sessions_clone, temp_dir_clone).await;
|
||||
});
|
||||
|
||||
service
|
||||
}
|
||||
|
||||
/// Background task to clean expired sessions
|
||||
async fn cleanup_loop(
|
||||
sessions: Arc<RwLock<HashMap<String, UploadSession>>>,
|
||||
temp_base_dir: PathBuf,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let expired: Vec<String> = {
|
||||
let sessions = sessions.read().await;
|
||||
sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.is_expired())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
for id in expired {
|
||||
let mut sessions = sessions.write().await;
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
// Clean up temp files
|
||||
if let Err(e) = fs::remove_dir_all(&session.temp_dir).await {
|
||||
tracing::warn!("Failed to cleanup expired upload {}: {}", id, e);
|
||||
} else {
|
||||
tracing::info!("🧹 Cleaned expired upload session: {}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean orphaned temp directories
|
||||
if let Ok(mut entries) = fs::read_dir(&temp_base_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let dir_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Check if this directory belongs to an active session
|
||||
let sessions = sessions.read().await;
|
||||
if !sessions.contains_key(dir_name) {
|
||||
// Check if directory is old (>24h)
|
||||
if let Ok(metadata) = fs::metadata(&path).await {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if modified.elapsed().unwrap_or_default() > SESSION_EXPIRATION {
|
||||
let _ = fs::remove_dir_all(&path).await;
|
||||
tracing::info!("🧹 Cleaned orphaned upload dir: {:?}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new upload session
|
||||
pub async fn create_session(
|
||||
&self,
|
||||
filename: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
total_size: u64,
|
||||
chunk_size: Option<usize>,
|
||||
) -> Result<CreateUploadResponse, String> {
|
||||
let upload_id = Uuid::new_v4().to_string();
|
||||
let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
|
||||
let chunk_count = UploadSession::calculate_chunk_count(total_size, chunk_size);
|
||||
|
||||
// Create temp directory for chunks
|
||||
let temp_dir = self.temp_base_dir.join(&upload_id);
|
||||
fs::create_dir_all(&temp_dir).await
|
||||
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
|
||||
|
||||
// Initialize chunk metadata
|
||||
let mut chunks = Vec::with_capacity(chunk_count);
|
||||
let mut offset: u64 = 0;
|
||||
|
||||
for i in 0..chunk_count {
|
||||
let size = if i == chunk_count - 1 {
|
||||
// Last chunk may be smaller
|
||||
(total_size - offset) as usize
|
||||
} else {
|
||||
chunk_size
|
||||
};
|
||||
|
||||
chunks.push(ChunkInfo {
|
||||
index: i,
|
||||
offset,
|
||||
size,
|
||||
status: ChunkStatus::Pending,
|
||||
checksum: None,
|
||||
});
|
||||
|
||||
offset += size as u64;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let session = UploadSession {
|
||||
id: upload_id.clone(),
|
||||
filename,
|
||||
folder_id,
|
||||
content_type,
|
||||
total_size,
|
||||
chunk_size,
|
||||
chunks,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
temp_dir,
|
||||
bytes_received: 0,
|
||||
};
|
||||
|
||||
let expires_at = (SESSION_EXPIRATION.as_secs()) as u64;
|
||||
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(upload_id.clone(), session);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"📤 Created chunked upload session: {} ({} chunks, {} bytes each)",
|
||||
upload_id, chunk_count, chunk_size
|
||||
);
|
||||
|
||||
Ok(CreateUploadResponse {
|
||||
upload_id,
|
||||
chunk_size,
|
||||
total_chunks: chunk_count,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Upload a single chunk
|
||||
pub async fn upload_chunk(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
chunk_index: usize,
|
||||
data: bytes::Bytes,
|
||||
checksum: Option<String>,
|
||||
) -> Result<ChunkUploadResponse, String> {
|
||||
// Validate session exists and chunk index is valid
|
||||
let (chunk_path, expected_size) = {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session = sessions.get(upload_id)
|
||||
.ok_or_else(|| format!("Upload session not found: {}", upload_id))?;
|
||||
|
||||
if chunk_index >= session.chunks.len() {
|
||||
return Err(format!("Invalid chunk index: {} (max: {})",
|
||||
chunk_index, session.chunks.len() - 1));
|
||||
}
|
||||
|
||||
let chunk = &session.chunks[chunk_index];
|
||||
if chunk.status == ChunkStatus::Complete {
|
||||
return Err(format!("Chunk {} already uploaded", chunk_index));
|
||||
}
|
||||
|
||||
(session.temp_dir.join(format!("chunk_{:06}", chunk_index)), chunk.size)
|
||||
};
|
||||
|
||||
// Validate chunk size
|
||||
if data.len() != expected_size {
|
||||
return Err(format!(
|
||||
"Invalid chunk size: expected {} bytes, got {} bytes",
|
||||
expected_size, data.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Verify checksum if provided
|
||||
if let Some(ref expected_checksum) = checksum {
|
||||
let actual_checksum = format!("{:x}", md5::compute(&data));
|
||||
if &actual_checksum != expected_checksum {
|
||||
return Err(format!(
|
||||
"Checksum mismatch: expected {}, got {}",
|
||||
expected_checksum, actual_checksum
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Write chunk to temp file
|
||||
let mut file = File::create(&chunk_path).await
|
||||
.map_err(|e| format!("Failed to create chunk file: {}", e))?;
|
||||
|
||||
file.write_all(&data).await
|
||||
.map_err(|e| format!("Failed to write chunk: {}", e))?;
|
||||
|
||||
file.sync_all().await
|
||||
.map_err(|e| format!("Failed to sync chunk: {}", e))?;
|
||||
|
||||
// Update session state
|
||||
let (bytes_received, progress, is_complete) = {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session = sessions.get_mut(upload_id)
|
||||
.ok_or_else(|| "Session disappeared".to_string())?;
|
||||
|
||||
session.chunks[chunk_index].status = ChunkStatus::Complete;
|
||||
session.chunks[chunk_index].checksum = checksum;
|
||||
session.bytes_received += data.len() as u64;
|
||||
session.last_activity = Instant::now();
|
||||
|
||||
(session.bytes_received, session.progress(), session.is_complete())
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"📦 Chunk {}/{} uploaded for {} ({:.1}% complete)",
|
||||
chunk_index + 1,
|
||||
expected_size,
|
||||
upload_id,
|
||||
progress * 100.0
|
||||
);
|
||||
|
||||
Ok(ChunkUploadResponse {
|
||||
chunk_index,
|
||||
bytes_received,
|
||||
progress,
|
||||
is_complete,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get upload status
|
||||
pub async fn get_status(&self, upload_id: &str) -> Result<UploadStatusResponse, String> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session = sessions.get(upload_id)
|
||||
.ok_or_else(|| format!("Upload session not found: {}", upload_id))?;
|
||||
|
||||
let completed_chunks = session.chunks
|
||||
.iter()
|
||||
.filter(|c| c.status == ChunkStatus::Complete)
|
||||
.count();
|
||||
|
||||
Ok(UploadStatusResponse {
|
||||
upload_id: session.id.clone(),
|
||||
filename: session.filename.clone(),
|
||||
total_size: session.total_size,
|
||||
bytes_received: session.bytes_received,
|
||||
progress: session.progress(),
|
||||
total_chunks: session.chunks.len(),
|
||||
completed_chunks,
|
||||
pending_chunks: session.pending_chunks(),
|
||||
is_complete: session.is_complete(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Assemble chunks into final file and return the path
|
||||
/// Returns (assembled_file_path, filename, folder_id, content_type, total_size)
|
||||
pub async fn complete_upload(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<(PathBuf, String, Option<String>, String, u64), String> {
|
||||
// Get session and validate completion
|
||||
let session = {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session = sessions.get(upload_id)
|
||||
.ok_or_else(|| format!("Upload session not found: {}", upload_id))?;
|
||||
|
||||
if !session.is_complete() {
|
||||
let pending = session.pending_chunks();
|
||||
return Err(format!(
|
||||
"Upload not complete. Missing chunks: {:?}",
|
||||
pending
|
||||
));
|
||||
}
|
||||
|
||||
session.clone()
|
||||
};
|
||||
|
||||
// Assemble file
|
||||
let assembled_path = session.temp_dir.join("assembled");
|
||||
let mut output = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&assembled_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create assembled file: {}", e))?;
|
||||
|
||||
// Append chunks in order
|
||||
for chunk in &session.chunks {
|
||||
let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index));
|
||||
let chunk_data = fs::read(&chunk_path).await
|
||||
.map_err(|e| format!("Failed to read chunk {}: {}", chunk.index, e))?;
|
||||
|
||||
output.write_all(&chunk_data).await
|
||||
.map_err(|e| format!("Failed to write chunk {} to assembled file: {}", chunk.index, e))?;
|
||||
}
|
||||
|
||||
output.sync_all().await
|
||||
.map_err(|e| format!("Failed to sync assembled file: {}", e))?;
|
||||
|
||||
// Clean up chunk files (keep assembled)
|
||||
for chunk in &session.chunks {
|
||||
let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index));
|
||||
let _ = fs::remove_file(&chunk_path).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"✅ Assembled chunked upload: {} ({} bytes from {} chunks)",
|
||||
session.filename,
|
||||
session.total_size,
|
||||
session.chunks.len()
|
||||
);
|
||||
|
||||
Ok((
|
||||
assembled_path,
|
||||
session.filename.clone(),
|
||||
session.folder_id.clone(),
|
||||
session.content_type.clone(),
|
||||
session.total_size,
|
||||
))
|
||||
}
|
||||
|
||||
/// Finalize upload: move assembled file to final location and cleanup session
|
||||
pub async fn finalize_upload(&self, upload_id: &str) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session) = sessions.remove(upload_id) {
|
||||
// Clean up entire temp directory
|
||||
if let Err(e) = fs::remove_dir_all(&session.temp_dir).await {
|
||||
tracing::warn!("Failed to cleanup upload {}: {}", upload_id, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel an upload and cleanup
|
||||
pub async fn cancel_upload(&self, upload_id: &str) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session) = sessions.remove(upload_id) {
|
||||
if let Err(e) = fs::remove_dir_all(&session.temp_dir).await {
|
||||
tracing::warn!("Failed to cleanup cancelled upload {}: {}", upload_id, e);
|
||||
}
|
||||
tracing::info!("❌ Cancelled chunked upload: {}", upload_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if file size qualifies for chunked upload
|
||||
pub fn should_use_chunked(size: u64) -> bool {
|
||||
size as usize >= CHUNKED_UPLOAD_THRESHOLD
|
||||
}
|
||||
|
||||
/// Get active session count (for monitoring)
|
||||
pub async fn active_sessions(&self) -> usize {
|
||||
self.sessions.read().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_count_calculation() {
|
||||
// 10MB file with 5MB chunks = 2 chunks
|
||||
assert_eq!(UploadSession::calculate_chunk_count(10 * 1024 * 1024, 5 * 1024 * 1024), 2);
|
||||
|
||||
// 11MB file with 5MB chunks = 3 chunks
|
||||
assert_eq!(UploadSession::calculate_chunk_count(11 * 1024 * 1024, 5 * 1024 * 1024), 3);
|
||||
|
||||
// 1 byte file = 1 chunk
|
||||
assert_eq!(UploadSession::calculate_chunk_count(1, 5 * 1024 * 1024), 1);
|
||||
|
||||
// 0 byte file = 1 chunk
|
||||
assert_eq!(UploadSession::calculate_chunk_count(0, 5 * 1024 * 1024), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_use_chunked() {
|
||||
assert!(!ChunkedUploadService::should_use_chunked(9 * 1024 * 1024));
|
||||
assert!(ChunkedUploadService::should_use_chunked(10 * 1024 * 1024));
|
||||
assert!(ChunkedUploadService::should_use_chunked(100 * 1024 * 1024));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
//! Content-Addressable Storage with Deduplication
|
||||
//!
|
||||
//! Implements hash-based deduplication to eliminate redundant file storage.
|
||||
//! Files are stored by their SHA-256 hash, and multiple references can point
|
||||
//! to the same physical blob.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ```
|
||||
//! ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
//! │ User Files │────▶│ Dedup Index │────▶│ Blob Store │
|
||||
//! │ (references) │ │ (hash→metadata) │ │ (actual data) │
|
||||
//! └─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! Benefits:
|
||||
//! - 30-50% storage reduction typical
|
||||
//! - Faster uploads for existing content (instant dedup)
|
||||
//! - Efficient backups
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::{AsyncReadExt, BufReader};
|
||||
use tokio::sync::RwLock;
|
||||
use sha2::{Sha256, Digest};
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Chunk size for streaming hash calculation (256KB)
|
||||
const HASH_CHUNK_SIZE: usize = 256 * 1024;
|
||||
|
||||
/// Minimum file size for deduplication (skip tiny files)
|
||||
const MIN_DEDUP_SIZE: u64 = 4096; // 4KB
|
||||
|
||||
/// Blob metadata stored in the dedup index
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobMetadata {
|
||||
/// 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,
|
||||
/// When the blob was first stored
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Original content type (for serving)
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a dedup operation
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DedupResult {
|
||||
/// New content was stored
|
||||
NewBlob {
|
||||
hash: String,
|
||||
size: u64,
|
||||
blob_path: PathBuf,
|
||||
},
|
||||
/// Content already existed, reference added
|
||||
ExistingBlob {
|
||||
hash: String,
|
||||
size: u64,
|
||||
blob_path: PathBuf,
|
||||
saved_bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl DedupResult {
|
||||
pub fn hash(&self) -> &str {
|
||||
match self {
|
||||
DedupResult::NewBlob { hash, .. } => hash,
|
||||
DedupResult::ExistingBlob { hash, .. } => hash,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> u64 {
|
||||
match self {
|
||||
DedupResult::NewBlob { size, .. } => *size,
|
||||
DedupResult::ExistingBlob { size, .. } => *size,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blob_path(&self) -> &Path {
|
||||
match self {
|
||||
DedupResult::NewBlob { blob_path, .. } => blob_path,
|
||||
DedupResult::ExistingBlob { blob_path, .. } => blob_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn was_deduplicated(&self) -> bool {
|
||||
matches!(self, DedupResult::ExistingBlob { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics for the dedup service
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct DedupStats {
|
||||
/// 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 dedup hits
|
||||
pub dedup_hits: u64,
|
||||
/// Deduplication ratio (referenced / stored)
|
||||
pub dedup_ratio: f64,
|
||||
}
|
||||
|
||||
/// Content-Addressable Storage Service
|
||||
pub struct DedupService {
|
||||
/// Root directory for blob storage
|
||||
blob_root: PathBuf,
|
||||
/// Root directory for temporary files during upload
|
||||
temp_root: PathBuf,
|
||||
/// In-memory index of blobs (hash -> metadata)
|
||||
index: Arc<RwLock<HashMap<String, BlobMetadata>>>,
|
||||
/// Path to persistent index file
|
||||
index_path: PathBuf,
|
||||
/// Statistics
|
||||
stats: Arc<RwLock<DedupStats>>,
|
||||
}
|
||||
|
||||
impl DedupService {
|
||||
/// Create a new dedup service
|
||||
pub fn new(storage_root: &Path) -> Self {
|
||||
let blob_root = storage_root.join(".blobs");
|
||||
let temp_root = storage_root.join(".dedup_temp");
|
||||
let index_path = storage_root.join(".dedup_index.json");
|
||||
|
||||
Self {
|
||||
blob_root,
|
||||
temp_root,
|
||||
index: Arc::new(RwLock::new(HashMap::new())),
|
||||
index_path,
|
||||
stats: Arc::new(RwLock::new(DedupStats::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the service (create directories, load index)
|
||||
pub async fn initialize(&self) -> std::io::Result<()> {
|
||||
// Create directories
|
||||
fs::create_dir_all(&self.blob_root).await?;
|
||||
fs::create_dir_all(&self.temp_root).await?;
|
||||
|
||||
// Create hash prefix directories (00-ff)
|
||||
for i in 0..=255u8 {
|
||||
let prefix = format!("{:02x}", i);
|
||||
fs::create_dir_all(self.blob_root.join(&prefix)).await?;
|
||||
}
|
||||
|
||||
// Load existing index
|
||||
self.load_index().await?;
|
||||
|
||||
tracing::info!(
|
||||
"🔗 Dedup service initialized: {} blobs, {} bytes stored",
|
||||
self.stats.read().await.total_blobs,
|
||||
self.stats.read().await.total_bytes_stored
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load index from disk
|
||||
async fn load_index(&self) -> std::io::Result<()> {
|
||||
if !self.index_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&self.index_path).await?;
|
||||
let entries: Vec<BlobMetadata> = serde_json::from_str(&content)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
let mut index = self.index.write().await;
|
||||
let mut stats = self.stats.write().await;
|
||||
|
||||
for entry in entries {
|
||||
stats.total_blobs += 1;
|
||||
stats.total_bytes_stored += entry.size;
|
||||
stats.total_bytes_referenced += entry.size * entry.ref_count as u64;
|
||||
|
||||
index.insert(entry.hash.clone(), entry);
|
||||
}
|
||||
|
||||
stats.bytes_saved = stats.total_bytes_referenced.saturating_sub(stats.total_bytes_stored);
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save index to disk
|
||||
async fn save_index(&self) -> std::io::Result<()> {
|
||||
let index = self.index.read().await;
|
||||
let entries: Vec<&BlobMetadata> = index.values().collect();
|
||||
let content = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Write atomically
|
||||
let temp_path = self.index_path.with_extension("json.tmp");
|
||||
fs::write(&temp_path, content).await?;
|
||||
fs::rename(&temp_path, &self.index_path).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the blob path for a given hash
|
||||
pub fn blob_path(&self, hash: &str) -> PathBuf {
|
||||
// Use first 2 chars as directory prefix for better filesystem distribution
|
||||
let prefix = &hash[0..2];
|
||||
self.blob_root.join(prefix).join(format!("{}.blob", hash))
|
||||
}
|
||||
|
||||
/// Calculate SHA-256 hash of content
|
||||
pub fn hash_bytes(content: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Calculate SHA-256 hash of a file (streaming)
|
||||
pub async fn hash_file(path: &Path) -> std::io::Result<String> {
|
||||
let file = File::open(path).await?;
|
||||
let mut reader = BufReader::with_capacity(HASH_CHUNK_SIZE, file);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0u8; HASH_CHUNK_SIZE];
|
||||
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer).await?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Check if a blob exists
|
||||
pub async fn blob_exists(&self, hash: &str) -> bool {
|
||||
let index = self.index.read().await;
|
||||
index.contains_key(hash)
|
||||
}
|
||||
|
||||
/// Get blob metadata
|
||||
pub async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadata> {
|
||||
let index = self.index.read().await;
|
||||
index.get(hash).cloned()
|
||||
}
|
||||
|
||||
/// Store content with deduplication (from bytes)
|
||||
pub async fn store_bytes(
|
||||
&self,
|
||||
content: &[u8],
|
||||
content_type: Option<String>,
|
||||
) -> Result<DedupResult, String> {
|
||||
let size = content.len() as u64;
|
||||
|
||||
// Skip dedup for tiny files
|
||||
if size < MIN_DEDUP_SIZE {
|
||||
return self.store_new_blob_from_bytes(content, content_type).await;
|
||||
}
|
||||
|
||||
// Calculate hash
|
||||
let hash = Self::hash_bytes(content);
|
||||
|
||||
// Check if already exists
|
||||
if self.blob_exists(&hash).await {
|
||||
// Increment reference count
|
||||
self.increment_ref_count(&hash).await?;
|
||||
|
||||
let blob_path = self.blob_path(&hash);
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.dedup_hits += 1;
|
||||
stats.bytes_saved += size;
|
||||
stats.total_bytes_referenced += size;
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("🔗 DEDUP HIT: {} ({} bytes saved)", &hash[..12], size);
|
||||
|
||||
return Ok(DedupResult::ExistingBlob {
|
||||
hash,
|
||||
size,
|
||||
blob_path,
|
||||
saved_bytes: size,
|
||||
});
|
||||
}
|
||||
|
||||
// Store new blob
|
||||
self.store_new_blob_from_bytes_with_hash(content, content_type, hash).await
|
||||
}
|
||||
|
||||
/// Store new blob from bytes (no dedup check)
|
||||
async fn store_new_blob_from_bytes(
|
||||
&self,
|
||||
content: &[u8],
|
||||
content_type: Option<String>,
|
||||
) -> Result<DedupResult, String> {
|
||||
let hash = Self::hash_bytes(content);
|
||||
self.store_new_blob_from_bytes_with_hash(content, content_type, hash).await
|
||||
}
|
||||
|
||||
/// Store new blob from bytes with known hash
|
||||
async fn store_new_blob_from_bytes_with_hash(
|
||||
&self,
|
||||
content: &[u8],
|
||||
content_type: Option<String>,
|
||||
hash: String,
|
||||
) -> Result<DedupResult, String> {
|
||||
let size = content.len() as u64;
|
||||
let blob_path = self.blob_path(&hash);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = blob_path.parent() {
|
||||
fs::create_dir_all(parent).await
|
||||
.map_err(|e| format!("Failed to create blob directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Write blob atomically
|
||||
let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
fs::write(&temp_path, content).await
|
||||
.map_err(|e| format!("Failed to write temp blob: {}", e))?;
|
||||
|
||||
fs::rename(&temp_path, &blob_path).await
|
||||
.map_err(|e| format!("Failed to move blob to final location: {}", e))?;
|
||||
|
||||
// Register in index
|
||||
let metadata = BlobMetadata {
|
||||
hash: hash.clone(),
|
||||
size,
|
||||
ref_count: 1,
|
||||
created_at: chrono::Utc::now(),
|
||||
content_type,
|
||||
};
|
||||
|
||||
{
|
||||
let mut index = self.index.write().await;
|
||||
index.insert(hash.clone(), metadata);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.total_blobs += 1;
|
||||
stats.total_bytes_stored += size;
|
||||
stats.total_bytes_referenced += size;
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Save index periodically (every 100 new blobs or async)
|
||||
let save_index = self.stats.read().await.total_blobs % 100 == 0;
|
||||
if save_index {
|
||||
let _ = self.save_index().await;
|
||||
}
|
||||
|
||||
tracing::info!("💾 NEW BLOB: {} ({} bytes)", &hash[..12], size);
|
||||
|
||||
Ok(DedupResult::NewBlob {
|
||||
hash,
|
||||
size,
|
||||
blob_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store content with deduplication (streaming from file)
|
||||
pub async fn store_from_file(
|
||||
&self,
|
||||
source_path: &Path,
|
||||
content_type: Option<String>,
|
||||
) -> Result<DedupResult, String> {
|
||||
let file_size = fs::metadata(source_path).await
|
||||
.map_err(|e| format!("Failed to get file metadata: {}", e))?
|
||||
.len();
|
||||
|
||||
// Skip dedup for tiny files
|
||||
if file_size < MIN_DEDUP_SIZE {
|
||||
let content = fs::read(source_path).await
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
return self.store_new_blob_from_bytes(&content, content_type).await;
|
||||
}
|
||||
|
||||
// Calculate hash (streaming)
|
||||
let hash = Self::hash_file(source_path).await
|
||||
.map_err(|e| format!("Failed to hash file: {}", e))?;
|
||||
|
||||
// Check if already exists
|
||||
if self.blob_exists(&hash).await {
|
||||
// Increment reference count
|
||||
self.increment_ref_count(&hash).await?;
|
||||
|
||||
let blob_path = self.blob_path(&hash);
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.dedup_hits += 1;
|
||||
stats.bytes_saved += file_size;
|
||||
stats.total_bytes_referenced += file_size;
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete source file since we don't need it
|
||||
let _ = fs::remove_file(source_path).await;
|
||||
|
||||
tracing::info!("🔗 DEDUP HIT (file): {} ({} bytes saved)", &hash[..12], file_size);
|
||||
|
||||
return Ok(DedupResult::ExistingBlob {
|
||||
hash,
|
||||
size: file_size,
|
||||
blob_path,
|
||||
saved_bytes: file_size,
|
||||
});
|
||||
}
|
||||
|
||||
// Move file to blob store
|
||||
let blob_path = self.blob_path(&hash);
|
||||
|
||||
if let Some(parent) = blob_path.parent() {
|
||||
fs::create_dir_all(parent).await
|
||||
.map_err(|e| format!("Failed to create blob directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(source_path, &blob_path).await
|
||||
.map_err(|e| format!("Failed to move file to blob store: {}", e))?;
|
||||
|
||||
// Register in index
|
||||
let metadata = BlobMetadata {
|
||||
hash: hash.clone(),
|
||||
size: file_size,
|
||||
ref_count: 1,
|
||||
created_at: chrono::Utc::now(),
|
||||
content_type,
|
||||
};
|
||||
|
||||
{
|
||||
let mut index = self.index.write().await;
|
||||
index.insert(hash.clone(), metadata);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.total_blobs += 1;
|
||||
stats.total_bytes_stored += file_size;
|
||||
stats.total_bytes_referenced += file_size;
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("💾 NEW BLOB (file): {} ({} bytes)", &hash[..12], file_size);
|
||||
|
||||
Ok(DedupResult::NewBlob {
|
||||
hash,
|
||||
size: file_size,
|
||||
blob_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Increment reference count for a blob
|
||||
async fn increment_ref_count(&self, hash: &str) -> Result<(), String> {
|
||||
let mut index = self.index.write().await;
|
||||
|
||||
if let Some(metadata) = index.get_mut(hash) {
|
||||
metadata.ref_count += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Blob not found: {}", hash))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a reference to a blob (used when creating file references)
|
||||
pub async fn add_reference(&self, hash: &str) -> Result<(), String> {
|
||||
self.increment_ref_count(hash).await?;
|
||||
|
||||
// Update stats
|
||||
if let Some(metadata) = self.get_blob_metadata(hash).await {
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.total_bytes_referenced += metadata.size;
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a reference to a blob, delete blob if ref_count reaches 0
|
||||
pub async fn remove_reference(&self, hash: &str) -> Result<bool, String> {
|
||||
let should_delete = {
|
||||
let mut index = self.index.write().await;
|
||||
|
||||
if let Some(metadata) = index.get_mut(hash) {
|
||||
metadata.ref_count = metadata.ref_count.saturating_sub(1);
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.total_bytes_referenced = stats.total_bytes_referenced.saturating_sub(metadata.size);
|
||||
stats.bytes_saved = stats.bytes_saved.saturating_sub(metadata.size);
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
}
|
||||
}
|
||||
|
||||
metadata.ref_count == 0
|
||||
} else {
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
if should_delete {
|
||||
// Remove from index
|
||||
let removed_metadata = {
|
||||
let mut index = self.index.write().await;
|
||||
index.remove(hash)
|
||||
};
|
||||
|
||||
if let Some(metadata) = removed_metadata {
|
||||
// Delete blob file
|
||||
let blob_path = self.blob_path(hash);
|
||||
if let Err(e) = fs::remove_file(&blob_path).await {
|
||||
tracing::warn!("Failed to delete blob {}: {}", hash, e);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.total_blobs = stats.total_blobs.saturating_sub(1);
|
||||
stats.total_bytes_stored = stats.total_bytes_stored.saturating_sub(metadata.size);
|
||||
if stats.total_bytes_stored > 0 {
|
||||
stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64;
|
||||
} else {
|
||||
stats.dedup_ratio = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("🗑️ BLOB DELETED: {} (no more references)", &hash[..12]);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
tracing::debug!("📎 Reference removed from blob {}", &hash[..12]);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read blob content
|
||||
pub async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, String> {
|
||||
let blob_path = self.blob_path(hash);
|
||||
|
||||
if !blob_path.exists() {
|
||||
return Err(format!("Blob not found: {}", hash));
|
||||
}
|
||||
|
||||
fs::read(&blob_path).await
|
||||
.map_err(|e| format!("Failed to read blob: {}", e))
|
||||
}
|
||||
|
||||
/// Read blob as Bytes
|
||||
pub async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, String> {
|
||||
self.read_blob(hash).await.map(Bytes::from)
|
||||
}
|
||||
|
||||
/// Get statistics
|
||||
pub async fn get_stats(&self) -> DedupStats {
|
||||
self.stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// Flush index to disk
|
||||
pub async fn flush(&self) -> std::io::Result<()> {
|
||||
self.save_index().await
|
||||
}
|
||||
|
||||
/// Verify integrity of all blobs
|
||||
pub async fn verify_integrity(&self) -> Result<Vec<String>, String> {
|
||||
let mut corrupted = Vec::new();
|
||||
let index = self.index.read().await;
|
||||
|
||||
for (hash, metadata) in index.iter() {
|
||||
let blob_path = self.blob_path(hash);
|
||||
|
||||
// Check file exists
|
||||
if !blob_path.exists() {
|
||||
corrupted.push(format!("{}: file missing", hash));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify hash
|
||||
match Self::hash_file(&blob_path).await {
|
||||
Ok(actual_hash) => {
|
||||
if actual_hash != *hash {
|
||||
corrupted.push(format!("{}: hash mismatch (actual: {})", hash, actual_hash));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
corrupted.push(format!("{}: read error ({})", hash, e));
|
||||
}
|
||||
}
|
||||
|
||||
// Check size
|
||||
if let Ok(file_meta) = fs::metadata(&blob_path).await {
|
||||
if file_meta.len() != metadata.size {
|
||||
corrupted.push(format!(
|
||||
"{}: size mismatch (expected: {}, actual: {})",
|
||||
hash, metadata.size, file_meta.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if corrupted.is_empty() {
|
||||
tracing::info!("✅ Integrity check passed for {} blobs", index.len());
|
||||
} else {
|
||||
tracing::warn!("⚠️ Integrity check found {} issues", corrupted.len());
|
||||
}
|
||||
|
||||
Ok(corrupted)
|
||||
}
|
||||
|
||||
/// Garbage collect orphaned blobs (blobs with ref_count=0)
|
||||
pub async fn garbage_collect(&self) -> Result<(u64, u64), String> {
|
||||
let orphans: Vec<(String, u64)> = {
|
||||
let index = self.index.read().await;
|
||||
index.iter()
|
||||
.filter(|(_, m)| m.ref_count == 0)
|
||||
.map(|(h, m)| (h.clone(), m.size))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut deleted_count = 0u64;
|
||||
let mut deleted_bytes = 0u64;
|
||||
|
||||
for (hash, size) in orphans {
|
||||
if self.remove_reference(&hash).await.is_ok() {
|
||||
deleted_count += 1;
|
||||
deleted_bytes += size;
|
||||
}
|
||||
}
|
||||
|
||||
if deleted_count > 0 {
|
||||
let _ = self.save_index().await;
|
||||
tracing::info!(
|
||||
"🧹 Garbage collected {} blobs ({} bytes)",
|
||||
deleted_count, deleted_bytes
|
||||
);
|
||||
}
|
||||
|
||||
Ok((deleted_count, deleted_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dedup_identical_content() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let service = DedupService::new(temp_dir.path());
|
||||
service.initialize().await.unwrap();
|
||||
|
||||
let content = b"Hello, World! This is test content.";
|
||||
|
||||
// First store
|
||||
let result1 = service.store_bytes(content, None).await.unwrap();
|
||||
assert!(!result1.was_deduplicated());
|
||||
|
||||
// Second store (same content)
|
||||
let result2 = service.store_bytes(content, None).await.unwrap();
|
||||
assert!(result2.was_deduplicated());
|
||||
assert_eq!(result1.hash(), result2.hash());
|
||||
|
||||
// Check stats
|
||||
let stats = service.get_stats().await;
|
||||
assert_eq!(stats.total_blobs, 1);
|
||||
assert_eq!(stats.dedup_hits, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reference_counting() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let service = DedupService::new(temp_dir.path());
|
||||
service.initialize().await.unwrap();
|
||||
|
||||
let content = b"Test content for reference counting";
|
||||
|
||||
// Store twice
|
||||
let result1 = service.store_bytes(content, None).await.unwrap();
|
||||
let _result2 = service.store_bytes(content, None).await.unwrap();
|
||||
|
||||
let hash = result1.hash().to_string();
|
||||
|
||||
// Check ref count
|
||||
let metadata = service.get_blob_metadata(&hash).await.unwrap();
|
||||
assert_eq!(metadata.ref_count, 2);
|
||||
|
||||
// Remove one reference
|
||||
let deleted = service.remove_reference(&hash).await.unwrap();
|
||||
assert!(!deleted);
|
||||
|
||||
// Remove second reference (should delete)
|
||||
let deleted = service.remove_reference(&hash).await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
// Blob should be gone
|
||||
assert!(!service.blob_exists(&hash).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use bytes::Bytes;
|
||||
use lru::LruCache;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Configuration for the file content cache
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileContentCacheConfig {
|
||||
/// Maximum size of individual files to cache (bytes)
|
||||
pub max_file_size: usize,
|
||||
/// Maximum total cache size (bytes)
|
||||
pub max_total_size: usize,
|
||||
/// Maximum number of entries
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
impl Default for FileContentCacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_file_size: 10 * 1024 * 1024, // 10MB max per file
|
||||
max_total_size: 512 * 1024 * 1024, // 512MB total cache
|
||||
max_entries: 10000, // Max 10k files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileContentCacheConfig {
|
||||
/// Create a new configuration with custom values
|
||||
pub fn new(max_file_mb: usize, max_total_mb: usize, max_entries: usize) -> Self {
|
||||
Self {
|
||||
max_file_size: max_file_mb * 1024 * 1024,
|
||||
max_total_size: max_total_mb * 1024 * 1024,
|
||||
max_entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache entry with metadata
|
||||
#[derive(Clone)]
|
||||
struct CacheEntry {
|
||||
content: Bytes,
|
||||
etag: String,
|
||||
content_type: String,
|
||||
}
|
||||
|
||||
/// LRU-based file content cache for small/frequently accessed files
|
||||
///
|
||||
/// This cache stores the actual content of files in memory for ultra-fast access.
|
||||
/// It uses an LRU eviction policy and respects memory limits.
|
||||
pub struct FileContentCache {
|
||||
cache: RwLock<LruCache<String, CacheEntry>>,
|
||||
config: FileContentCacheConfig,
|
||||
current_size: AtomicUsize,
|
||||
hits: AtomicUsize,
|
||||
misses: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FileContentCache {
|
||||
/// Create a new file content cache with the given configuration
|
||||
pub fn new(config: FileContentCacheConfig) -> Self {
|
||||
let max_entries = NonZeroUsize::new(config.max_entries).unwrap_or(NonZeroUsize::new(1000).unwrap());
|
||||
|
||||
info!(
|
||||
"Initializing FileContentCache: max_file={}MB, max_total={}MB, max_entries={}",
|
||||
config.max_file_size / (1024 * 1024),
|
||||
config.max_total_size / (1024 * 1024),
|
||||
config.max_entries
|
||||
);
|
||||
|
||||
Self {
|
||||
cache: RwLock::new(LruCache::new(max_entries)),
|
||||
config,
|
||||
current_size: AtomicUsize::new(0),
|
||||
hits: AtomicUsize::new(0),
|
||||
misses: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a cache with default configuration
|
||||
pub fn default() -> Self {
|
||||
Self::new(FileContentCacheConfig::default())
|
||||
}
|
||||
|
||||
/// Check if a file should be cached based on its size
|
||||
pub fn should_cache(&self, size: usize) -> bool {
|
||||
size <= self.config.max_file_size
|
||||
}
|
||||
|
||||
/// Get file content from cache
|
||||
///
|
||||
/// Returns (content, etag, content_type) if found
|
||||
pub async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> {
|
||||
let mut cache = self.cache.write().await;
|
||||
|
||||
if let Some(entry) = cache.get(file_id) {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
debug!("Cache HIT for file: {}", file_id);
|
||||
return Some((entry.content.clone(), entry.etag.clone(), entry.content_type.clone()));
|
||||
}
|
||||
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
debug!("Cache MISS for file: {}", file_id);
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if file exists in cache without updating LRU order
|
||||
pub async fn contains(&self, file_id: &str) -> bool {
|
||||
let cache = self.cache.read().await;
|
||||
cache.contains(file_id)
|
||||
}
|
||||
|
||||
/// Put file content into cache
|
||||
///
|
||||
/// Will evict older entries if necessary to make room.
|
||||
/// Will not cache if file is too large.
|
||||
pub async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) {
|
||||
let size = content.len();
|
||||
|
||||
// Don't cache if too large
|
||||
if size > self.config.max_file_size {
|
||||
debug!("File {} too large to cache: {} bytes", file_id, size);
|
||||
return;
|
||||
}
|
||||
|
||||
// Evict entries until we have room
|
||||
while self.current_size.load(Ordering::Relaxed) + size > self.config.max_total_size {
|
||||
let mut cache = self.cache.write().await;
|
||||
if let Some((evicted_id, evicted_entry)) = cache.pop_lru() {
|
||||
let evicted_size = evicted_entry.content.len();
|
||||
self.current_size.fetch_sub(evicted_size, Ordering::Relaxed);
|
||||
debug!("Evicted file {} ({} bytes) from cache", evicted_id, evicted_size);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check again after eviction
|
||||
if self.current_size.load(Ordering::Relaxed) + size > self.config.max_total_size {
|
||||
warn!("Cannot cache file {}: no room after eviction", file_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let entry = CacheEntry {
|
||||
content,
|
||||
etag,
|
||||
content_type,
|
||||
};
|
||||
|
||||
let mut cache = self.cache.write().await;
|
||||
|
||||
// If replacing an existing entry, subtract its size first
|
||||
if let Some(old_entry) = cache.peek(&file_id) {
|
||||
self.current_size.fetch_sub(old_entry.content.len(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
cache.put(file_id.clone(), entry);
|
||||
self.current_size.fetch_add(size, Ordering::Relaxed);
|
||||
|
||||
debug!("Cached file {} ({} bytes)", file_id, size);
|
||||
}
|
||||
|
||||
/// Remove a file from cache (e.g., when file is deleted or modified)
|
||||
pub async fn invalidate(&self, file_id: &str) {
|
||||
let mut cache = self.cache.write().await;
|
||||
if let Some(entry) = cache.pop(file_id) {
|
||||
self.current_size.fetch_sub(entry.content.len(), Ordering::Relaxed);
|
||||
debug!("Invalidated cache for file: {}", file_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the entire cache
|
||||
pub async fn clear(&self) {
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.clear();
|
||||
self.current_size.store(0, Ordering::Relaxed);
|
||||
info!("Cache cleared");
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub fn stats(&self) -> CacheStats {
|
||||
let hits = self.hits.load(Ordering::Relaxed);
|
||||
let misses = self.misses.load(Ordering::Relaxed);
|
||||
let total = hits + misses;
|
||||
let hit_rate = if total > 0 {
|
||||
(hits as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
CacheStats {
|
||||
current_size_bytes: self.current_size.load(Ordering::Relaxed),
|
||||
max_size_bytes: self.config.max_total_size,
|
||||
hits,
|
||||
misses,
|
||||
hit_rate_percent: hit_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheStats {
|
||||
pub current_size_bytes: usize,
|
||||
pub max_size_bytes: usize,
|
||||
pub hits: usize,
|
||||
pub misses: usize,
|
||||
pub hit_rate_percent: f64,
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for sharing across handlers
|
||||
pub type SharedFileContentCache = Arc<FileContentCache>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_put_get() {
|
||||
let cache = FileContentCache::new(FileContentCacheConfig {
|
||||
max_file_size: 1024,
|
||||
max_total_size: 4096,
|
||||
max_entries: 100,
|
||||
});
|
||||
|
||||
let content = Bytes::from("Hello, World!");
|
||||
cache.put(
|
||||
"file1".to_string(),
|
||||
content.clone(),
|
||||
"etag1".to_string(),
|
||||
"text/plain".to_string()
|
||||
).await;
|
||||
|
||||
let result = cache.get("file1").await;
|
||||
assert!(result.is_some());
|
||||
let (cached_content, etag, content_type) = result.unwrap();
|
||||
assert_eq!(cached_content, content);
|
||||
assert_eq!(etag, "etag1");
|
||||
assert_eq!(content_type, "text/plain");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_eviction() {
|
||||
let cache = FileContentCache::new(FileContentCacheConfig {
|
||||
max_file_size: 100,
|
||||
max_total_size: 200,
|
||||
max_entries: 100,
|
||||
});
|
||||
|
||||
// Add first file (100 bytes)
|
||||
let content1 = Bytes::from(vec![0u8; 100]);
|
||||
cache.put("file1".to_string(), content1, "e1".to_string(), "app/bin".to_string()).await;
|
||||
|
||||
// Add second file (100 bytes)
|
||||
let content2 = Bytes::from(vec![1u8; 100]);
|
||||
cache.put("file2".to_string(), content2, "e2".to_string(), "app/bin".to_string()).await;
|
||||
|
||||
// Add third file - should evict file1
|
||||
let content3 = Bytes::from(vec![2u8; 100]);
|
||||
cache.put("file3".to_string(), content3, "e3".to_string(), "app/bin".to_string()).await;
|
||||
|
||||
// file1 should be evicted
|
||||
assert!(cache.get("file1").await.is_none());
|
||||
// file2 and file3 should exist
|
||||
assert!(cache.get("file2").await.is_some());
|
||||
assert!(cache.get("file3").await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_invalidate() {
|
||||
let cache = FileContentCache::new(FileContentCacheConfig::default());
|
||||
|
||||
let content = Bytes::from("test");
|
||||
cache.put("file1".to_string(), content, "e".to_string(), "t".to_string()).await;
|
||||
|
||||
assert!(cache.get("file1").await.is_some());
|
||||
|
||||
cache.invalidate("file1").await;
|
||||
|
||||
assert!(cache.get("file1").await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Image Transcoding Service - WebP On-Demand Conversion
|
||||
//!
|
||||
//! Automatically transcodes images to WebP format when the browser supports it,
|
||||
//! reducing bandwidth by 30-50% compared to JPEG/PNG.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Detects browser WebP support via Accept header
|
||||
//! - Caches transcoded versions to avoid re-conversion
|
||||
//! - Supports JPEG, PNG, GIF → WebP conversion
|
||||
//! - Configurable quality settings
|
||||
//! - Falls back to original if conversion fails
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::fs;
|
||||
use bytes::Bytes;
|
||||
use lru::LruCache;
|
||||
use std::num::NonZeroUsize;
|
||||
use image::{ImageFormat, DynamicImage};
|
||||
|
||||
/// Maximum file size for transcoding (5MB - larger files stream directly)
|
||||
pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// Cache key for transcoded images
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct TranscodeKey {
|
||||
file_id: String,
|
||||
format: OutputFormat,
|
||||
}
|
||||
|
||||
/// Supported output formats
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum OutputFormat {
|
||||
WebP,
|
||||
// Future: AVIF, JPEG-XL
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
pub fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::WebP => "webp",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime_type(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::WebP => "image/webp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of checking browser support
|
||||
#[derive(Debug)]
|
||||
pub struct BrowserCapabilities {
|
||||
pub supports_webp: bool,
|
||||
pub supports_avif: bool,
|
||||
}
|
||||
|
||||
impl BrowserCapabilities {
|
||||
/// Parse 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 for this browser
|
||||
pub fn best_format(&self) -> Option<OutputFormat> {
|
||||
// WebP has best support currently
|
||||
if self.supports_webp {
|
||||
Some(OutputFormat::WebP)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image Transcoding Service
|
||||
pub struct ImageTranscodeService {
|
||||
/// Cache directory for transcoded images
|
||||
cache_dir: PathBuf,
|
||||
/// In-memory LRU cache for hot transcoded images
|
||||
memory_cache: Arc<RwLock<LruCache<TranscodeKey, Bytes>>>,
|
||||
/// Maximum memory cache size in bytes
|
||||
max_memory_bytes: usize,
|
||||
/// Current memory usage
|
||||
current_memory_bytes: Arc<RwLock<usize>>,
|
||||
/// Statistics
|
||||
stats: Arc<RwLock<TranscodeStats>>,
|
||||
}
|
||||
|
||||
/// Transcoding statistics
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TranscodeStats {
|
||||
pub cache_hits: u64,
|
||||
pub disk_hits: u64,
|
||||
pub transcodes: u64,
|
||||
pub bytes_saved: u64,
|
||||
pub transcode_errors: u64,
|
||||
}
|
||||
|
||||
impl ImageTranscodeService {
|
||||
/// Create new transcoding service
|
||||
pub fn new(storage_root: &Path, max_cache_entries: usize, max_memory_bytes: usize) -> Self {
|
||||
let cache_dir = storage_root.join(".transcoded");
|
||||
|
||||
Self {
|
||||
cache_dir,
|
||||
memory_cache: Arc::new(RwLock::new(LruCache::new(
|
||||
NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap())
|
||||
))),
|
||||
max_memory_bytes,
|
||||
current_memory_bytes: Arc::new(RwLock::new(0)),
|
||||
stats: Arc::new(RwLock::new(TranscodeStats::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the service (create cache directories)
|
||||
pub async fn initialize(&self) -> std::io::Result<()> {
|
||||
fs::create_dir_all(&self.cache_dir).await?;
|
||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||
tracing::info!("🖼️ Image transcode service initialized at {:?}", self.cache_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a mime type can be transcoded
|
||||
pub fn can_transcode(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"image/jpeg" | "image/jpg" | "image/png" | "image/gif"
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if transcoding should be attempted based on file size and type
|
||||
pub fn should_transcode(mime_type: &str, file_size: u64) -> bool {
|
||||
Self::can_transcode(mime_type) && file_size <= MAX_TRANSCODE_SIZE
|
||||
}
|
||||
|
||||
/// Get transcoded version of an image
|
||||
/// Returns (content, mime_type, was_transcoded)
|
||||
pub async fn get_transcoded(
|
||||
&self,
|
||||
file_id: &str,
|
||||
original_content: &[u8],
|
||||
original_mime: &str,
|
||||
target_format: OutputFormat,
|
||||
) -> Result<(Bytes, String, bool), String> {
|
||||
let key = TranscodeKey {
|
||||
file_id: file_id.to_string(),
|
||||
format: target_format,
|
||||
};
|
||||
|
||||
// Check memory cache first
|
||||
{
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
if let Some(cached) = cache.get(&key) {
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.cache_hits += 1;
|
||||
tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id);
|
||||
return Ok((cached.clone(), target_format.mime_type().to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Check disk cache
|
||||
let cache_path = self.get_cache_path(file_id, target_format);
|
||||
if cache_path.exists() {
|
||||
match fs::read(&cache_path).await {
|
||||
Ok(data) => {
|
||||
let content = Bytes::from(data);
|
||||
|
||||
// Store in memory cache
|
||||
self.cache_in_memory(&key, content.clone()).await;
|
||||
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.disk_hits += 1;
|
||||
tracing::debug!("💾 Transcode disk cache HIT: {}", file_id);
|
||||
return Ok((content, target_format.mime_type().to_string(), true));
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read cached transcode: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Need to transcode
|
||||
let transcoded = self.transcode_image(original_content, original_mime, target_format)?;
|
||||
let transcoded_bytes = Bytes::from(transcoded.clone());
|
||||
|
||||
// Calculate savings
|
||||
let original_size = original_content.len();
|
||||
let transcoded_size = transcoded_bytes.len();
|
||||
let saved = if transcoded_size < original_size {
|
||||
original_size - transcoded_size
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Only use transcoded if it's actually smaller
|
||||
if transcoded_size >= original_size {
|
||||
tracing::debug!(
|
||||
"⚠️ Transcode not beneficial for {}: {} -> {} bytes",
|
||||
file_id, original_size, transcoded_size
|
||||
);
|
||||
return Ok((Bytes::from(original_content.to_vec()), original_mime.to_string(), false));
|
||||
}
|
||||
|
||||
// Save to disk cache (async, don't wait)
|
||||
let cache_path_clone = cache_path.clone();
|
||||
let transcoded_clone = transcoded.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(parent) = cache_path_clone.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
if let Err(e) = fs::write(&cache_path_clone, &transcoded_clone).await {
|
||||
tracing::warn!("Failed to cache transcoded image: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Store in memory cache
|
||||
self.cache_in_memory(&key, transcoded_bytes.clone()).await;
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.transcodes += 1;
|
||||
stats.bytes_saved += saved as u64;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"✨ Transcoded {}: {} -> {} bytes ({:.1}% smaller)",
|
||||
file_id,
|
||||
original_size,
|
||||
transcoded_size,
|
||||
(1.0 - transcoded_size as f64 / original_size as f64) * 100.0
|
||||
);
|
||||
|
||||
Ok((transcoded_bytes, target_format.mime_type().to_string(), true))
|
||||
}
|
||||
|
||||
/// Perform actual image transcoding
|
||||
fn transcode_image(
|
||||
&self,
|
||||
content: &[u8],
|
||||
original_mime: &str,
|
||||
target_format: OutputFormat,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
// Determine input format
|
||||
let input_format = match original_mime {
|
||||
"image/jpeg" | "image/jpg" => ImageFormat::Jpeg,
|
||||
"image/png" => ImageFormat::Png,
|
||||
"image/gif" => ImageFormat::Gif,
|
||||
_ => return Err(format!("Unsupported input format: {}", original_mime)),
|
||||
};
|
||||
|
||||
// Load image
|
||||
let img = image::load_from_memory_with_format(content, input_format)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
|
||||
// Encode to target format
|
||||
match target_format {
|
||||
OutputFormat::WebP => self.encode_webp(&img),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode image to WebP
|
||||
fn encode_webp(&self, img: &DynamicImage) -> Result<Vec<u8>, String> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut buffer);
|
||||
|
||||
// Use image crate's WebP encoder
|
||||
img.write_to(&mut cursor, ImageFormat::WebP)
|
||||
.map_err(|e| format!("Failed to encode WebP: {}", e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Get path for cached transcoded file
|
||||
fn get_cache_path(&self, file_id: &str, format: OutputFormat) -> PathBuf {
|
||||
self.cache_dir
|
||||
.join(format.extension())
|
||||
.join(format!("{}.{}", file_id, format.extension()))
|
||||
}
|
||||
|
||||
/// Store transcoded image in memory cache
|
||||
async fn cache_in_memory(&self, key: &TranscodeKey, content: Bytes) {
|
||||
let size = content.len();
|
||||
|
||||
let mut current = self.current_memory_bytes.write().await;
|
||||
|
||||
// Evict if needed
|
||||
while *current + size > self.max_memory_bytes {
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
if let Some((_, evicted)) = cache.pop_lru() {
|
||||
*current = current.saturating_sub(evicted.len());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cache
|
||||
if *current + size <= self.max_memory_bytes {
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
cache.put(key.clone(), content);
|
||||
*current += size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate cached transcodes for a file
|
||||
pub async fn invalidate(&self, file_id: &str) {
|
||||
// Remove from memory cache
|
||||
{
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
let key = TranscodeKey {
|
||||
file_id: file_id.to_string(),
|
||||
format: OutputFormat::WebP,
|
||||
};
|
||||
if let Some(removed) = cache.pop(&key) {
|
||||
let mut current = self.current_memory_bytes.write().await;
|
||||
*current = current.saturating_sub(removed.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove disk cache
|
||||
let cache_path = self.get_cache_path(file_id, OutputFormat::WebP);
|
||||
let _ = fs::remove_file(&cache_path).await;
|
||||
}
|
||||
|
||||
/// Get transcoding statistics
|
||||
pub async fn get_stats(&self) -> TranscodeStats {
|
||||
self.stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// Clear all caches
|
||||
pub async fn clear_cache(&self) -> std::io::Result<()> {
|
||||
// Clear memory
|
||||
{
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
cache.clear();
|
||||
let mut current = self.current_memory_bytes.write().await;
|
||||
*current = 0;
|
||||
}
|
||||
|
||||
// Clear disk
|
||||
if self.cache_dir.exists() {
|
||||
fs::remove_dir_all(&self.cache_dir).await?;
|
||||
fs::create_dir_all(&self.cache_dir).await?;
|
||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_browser_capabilities() {
|
||||
// Chrome/Firefox with WebP support
|
||||
let caps = BrowserCapabilities::from_accept_header(
|
||||
Some("image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
);
|
||||
assert!(caps.supports_webp);
|
||||
assert!(caps.supports_avif);
|
||||
|
||||
// Safari without WebP (old)
|
||||
let caps = BrowserCapabilities::from_accept_header(
|
||||
Some("image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5")
|
||||
);
|
||||
assert!(!caps.supports_webp);
|
||||
|
||||
// No header
|
||||
let caps = BrowserCapabilities::from_accept_header(None);
|
||||
assert!(!caps.supports_webp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_transcode() {
|
||||
assert!(ImageTranscodeService::can_transcode("image/jpeg"));
|
||||
assert!(ImageTranscodeService::can_transcode("image/png"));
|
||||
assert!(ImageTranscodeService::can_transcode("image/gif"));
|
||||
assert!(!ImageTranscodeService::can_transcode("image/webp"));
|
||||
assert!(!ImageTranscodeService::can_transcode("image/svg+xml"));
|
||||
assert!(!ImageTranscodeService::can_transcode("application/pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_transcode() {
|
||||
// Small JPEG - yes
|
||||
assert!(ImageTranscodeService::should_transcode("image/jpeg", 1024 * 1024));
|
||||
|
||||
// Large JPEG - no (too big)
|
||||
assert!(!ImageTranscodeService::should_transcode("image/jpeg", 10 * 1024 * 1024));
|
||||
|
||||
// WebP - no (already optimal)
|
||||
assert!(!ImageTranscodeService::should_transcode("image/webp", 1024 * 1024));
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@ 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;
|
||||
pub mod buffer_pool;
|
||||
pub mod trash_cleanup_service;
|
||||
pub mod zip_service;
|
||||
pub mod path_service;
|
||||
pub mod password_hasher;
|
||||
pub mod jwt_service;
|
||||
pub mod jwt_service;
|
||||
pub mod thumbnail_service;
|
||||
pub mod write_behind_cache;
|
||||
pub mod chunked_upload_service;
|
||||
pub mod image_transcode_service;
|
||||
pub mod dedup_service;
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Thumbnail Generation Service
|
||||
*
|
||||
* Generates and manages image thumbnails for fast gallery previews.
|
||||
*
|
||||
* Features:
|
||||
* - Background thumbnail generation after upload
|
||||
* - Multiple sizes (icon 150x150, preview 800x600)
|
||||
* - WebP output for smaller file sizes
|
||||
* - LRU cache for hot thumbnails
|
||||
* - Lazy generation on first request if not pre-generated
|
||||
*/
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::fs;
|
||||
use image::{ImageFormat, imageops::FilterType};
|
||||
use lru::LruCache;
|
||||
use std::num::NonZeroUsize;
|
||||
use bytes::Bytes;
|
||||
|
||||
/// Thumbnail sizes supported by the system
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ThumbnailSize {
|
||||
/// Small icon for file listings (150x150)
|
||||
Icon,
|
||||
/// Medium preview for gallery view (400x400)
|
||||
Preview,
|
||||
/// Large preview for detail view (800x800)
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for thumbnails
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ThumbnailCacheKey {
|
||||
file_id: String,
|
||||
size: ThumbnailSize,
|
||||
}
|
||||
|
||||
/// Thumbnail service for generating and caching image thumbnails
|
||||
pub struct ThumbnailService {
|
||||
/// Root path for thumbnail storage
|
||||
thumbnails_root: PathBuf,
|
||||
/// In-memory LRU cache for hot thumbnails
|
||||
cache: Arc<RwLock<LruCache<ThumbnailCacheKey, Bytes>>>,
|
||||
/// Maximum cache size in bytes
|
||||
max_cache_bytes: usize,
|
||||
/// Current cache size in bytes
|
||||
current_cache_bytes: Arc<RwLock<usize>>,
|
||||
}
|
||||
|
||||
impl ThumbnailService {
|
||||
/// Create a new thumbnail service
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `storage_root` - Root path of file storage
|
||||
/// * `max_cache_entries` - Maximum number of thumbnails to cache in memory
|
||||
/// * `max_cache_bytes` - Maximum total bytes to cache
|
||||
pub fn new(storage_root: &Path, max_cache_entries: usize, max_cache_bytes: usize) -> Self {
|
||||
let thumbnails_root = storage_root.join(".thumbnails");
|
||||
|
||||
Self {
|
||||
thumbnails_root,
|
||||
cache: Arc::new(RwLock::new(LruCache::new(
|
||||
NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap())
|
||||
))),
|
||||
max_cache_bytes,
|
||||
current_cache_bytes: Arc::new(RwLock::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the thumbnail directories
|
||||
pub async fn initialize(&self) -> std::io::Result<()> {
|
||||
for size in ThumbnailSize::all() {
|
||||
let dir = self.thumbnails_root.join(size.dir_name());
|
||||
fs::create_dir_all(&dir).await?;
|
||||
}
|
||||
tracing::info!("🖼️ Thumbnail service initialized at {:?}", self.thumbnails_root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a file is an image that can have thumbnails
|
||||
pub fn is_supported_image(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the path where a thumbnail would be stored
|
||||
fn get_thumbnail_path(&self, file_id: &str, size: ThumbnailSize) -> PathBuf {
|
||||
self.thumbnails_root
|
||||
.join(size.dir_name())
|
||||
.join(format!("{}.webp", file_id))
|
||||
}
|
||||
|
||||
/// Check if a thumbnail exists on disk
|
||||
pub async fn thumbnail_exists(&self, file_id: &str, size: ThumbnailSize) -> bool {
|
||||
let path = self.get_thumbnail_path(file_id, size);
|
||||
fs::metadata(&path).await.is_ok()
|
||||
}
|
||||
|
||||
/// Get a thumbnail, generating it if needed
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `file_id` - ID of the original file
|
||||
/// * `size` - Desired thumbnail size
|
||||
/// * `original_path` - Path to the original image file
|
||||
///
|
||||
/// # Returns
|
||||
/// Bytes of the thumbnail image (WebP format)
|
||||
pub async fn get_thumbnail(
|
||||
&self,
|
||||
file_id: &str,
|
||||
size: ThumbnailSize,
|
||||
original_path: &Path,
|
||||
) -> Result<Bytes, ThumbnailError> {
|
||||
let cache_key = ThumbnailCacheKey {
|
||||
file_id: file_id.to_string(),
|
||||
size,
|
||||
};
|
||||
|
||||
// Check in-memory cache first
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(data) = cache.peek(&cache_key) {
|
||||
tracing::debug!("🔥 Thumbnail cache HIT: {} {:?}", file_id, size);
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if thumbnail exists on disk
|
||||
let thumb_path = self.get_thumbnail_path(file_id, size);
|
||||
|
||||
if fs::metadata(&thumb_path).await.is_ok() {
|
||||
// Load from disk
|
||||
let data = fs::read(&thumb_path).await
|
||||
.map_err(|e| ThumbnailError::IoError(e.to_string()))?;
|
||||
let bytes = Bytes::from(data);
|
||||
|
||||
// Add to cache
|
||||
self.add_to_cache(cache_key, bytes.clone()).await;
|
||||
|
||||
tracing::debug!("💾 Thumbnail loaded from disk: {} {:?}", file_id, size);
|
||||
return Ok(bytes);
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
tracing::info!("🎨 Generating thumbnail: {} {:?}", file_id, size);
|
||||
let bytes = self.generate_thumbnail(original_path, size).await?;
|
||||
|
||||
// Save to disk
|
||||
if let Some(parent) = thumb_path.parent() {
|
||||
fs::create_dir_all(parent).await
|
||||
.map_err(|e| ThumbnailError::IoError(e.to_string()))?;
|
||||
}
|
||||
fs::write(&thumb_path, &bytes).await
|
||||
.map_err(|e| ThumbnailError::IoError(e.to_string()))?;
|
||||
|
||||
// Add to cache
|
||||
self.add_to_cache(cache_key, bytes.clone()).await;
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Generate a thumbnail from an image file
|
||||
async fn generate_thumbnail(
|
||||
&self,
|
||||
original_path: &Path,
|
||||
size: ThumbnailSize,
|
||||
) -> Result<Bytes, ThumbnailError> {
|
||||
let path = original_path.to_path_buf();
|
||||
let max_dim = size.max_dimension();
|
||||
|
||||
// Run image processing in blocking thread pool
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> {
|
||||
// Load image
|
||||
let img = image::open(&path)
|
||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||
|
||||
// Calculate new dimensions preserving aspect ratio
|
||||
let (orig_width, orig_height) = (img.width(), img.height());
|
||||
let (new_width, new_height) = if orig_width > orig_height {
|
||||
let ratio = max_dim as f32 / orig_width as f32;
|
||||
(max_dim, (orig_height as f32 * ratio) as u32)
|
||||
} else {
|
||||
let ratio = max_dim as f32 / orig_height as f32;
|
||||
((orig_width as f32 * ratio) as u32, max_dim)
|
||||
};
|
||||
|
||||
// Resize using high-quality Lanczos3 filter
|
||||
let thumbnail = img.resize(new_width, new_height, FilterType::Lanczos3);
|
||||
|
||||
// Encode as WebP for smaller file size
|
||||
let mut buffer = Vec::new();
|
||||
thumbnail.write_to(
|
||||
&mut std::io::Cursor::new(&mut buffer),
|
||||
ImageFormat::WebP
|
||||
).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||
|
||||
Ok(buffer)
|
||||
}).await
|
||||
.map_err(|e| ThumbnailError::TaskError(e.to_string()))?;
|
||||
|
||||
result.map(Bytes::from)
|
||||
}
|
||||
|
||||
/// Add a thumbnail to the in-memory cache
|
||||
async fn add_to_cache(&self, key: ThumbnailCacheKey, data: Bytes) {
|
||||
let data_size = data.len();
|
||||
|
||||
// Check if adding this would exceed max cache size
|
||||
let mut current_size = self.current_cache_bytes.write().await;
|
||||
|
||||
// Evict items if needed to make room
|
||||
if *current_size + data_size > self.max_cache_bytes {
|
||||
let mut cache = self.cache.write().await;
|
||||
while *current_size + data_size > self.max_cache_bytes && !cache.is_empty() {
|
||||
if let Some((_, evicted)) = cache.pop_lru() {
|
||||
*current_size = current_size.saturating_sub(evicted.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cache
|
||||
let mut cache = self.cache.write().await;
|
||||
if let Some(old) = cache.put(key, data) {
|
||||
*current_size = current_size.saturating_sub(old.len());
|
||||
}
|
||||
*current_size += data_size;
|
||||
}
|
||||
|
||||
/// Generate all thumbnail sizes for a file in the background
|
||||
///
|
||||
/// This is called after file upload to pre-generate thumbnails
|
||||
pub fn generate_all_sizes_background(
|
||||
self: Arc<Self>,
|
||||
file_id: String,
|
||||
original_path: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("🖼️ Background thumbnail generation starting: {}", file_id);
|
||||
|
||||
for size in ThumbnailSize::all() {
|
||||
match self.generate_thumbnail(&original_path, *size).await {
|
||||
Ok(bytes) => {
|
||||
// Save to disk
|
||||
let thumb_path = self.get_thumbnail_path(&file_id, *size);
|
||||
if let Some(parent) = thumb_path.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
if let Err(e) = fs::write(&thumb_path, &bytes).await {
|
||||
tracing::warn!("Failed to save thumbnail {}: {}", file_id, e);
|
||||
} else {
|
||||
tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to generate thumbnail {} {:?}: {}", file_id, size, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("✅ Background thumbnail generation complete: {}", file_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete all thumbnails for a file
|
||||
pub async fn delete_thumbnails(&self, file_id: &str) -> Result<(), ThumbnailError> {
|
||||
for size in ThumbnailSize::all() {
|
||||
let path = self.get_thumbnail_path(file_id, *size);
|
||||
if fs::metadata(&path).await.is_ok() {
|
||||
fs::remove_file(&path).await
|
||||
.map_err(|e| ThumbnailError::IoError(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
let cache_key = ThumbnailCacheKey {
|
||||
file_id: file_id.to_string(),
|
||||
size: *size,
|
||||
};
|
||||
let mut cache = self.cache.write().await;
|
||||
if let Some(removed) = cache.pop(&cache_key) {
|
||||
let mut current_size = self.current_cache_bytes.write().await;
|
||||
*current_size = current_size.saturating_sub(removed.len());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("🗑️ Deleted thumbnails for: {}", file_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub async fn get_stats(&self) -> ThumbnailStats {
|
||||
let cache = self.cache.read().await;
|
||||
let current_size = *self.current_cache_bytes.read().await;
|
||||
|
||||
ThumbnailStats {
|
||||
cached_thumbnails: cache.len(),
|
||||
cache_size_bytes: current_size,
|
||||
max_cache_bytes: self.max_cache_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnail service errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ThumbnailError {
|
||||
#[error("IO error: {0}")]
|
||||
IoError(String),
|
||||
|
||||
#[error("Image processing error: {0}")]
|
||||
ImageError(String),
|
||||
|
||||
#[error("Task error: {0}")]
|
||||
TaskError(String),
|
||||
|
||||
#[error("Unsupported image format")]
|
||||
UnsupportedFormat,
|
||||
}
|
||||
|
||||
/// Statistics about the thumbnail cache
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThumbnailStats {
|
||||
pub cached_thumbnails: usize,
|
||||
pub cache_size_bytes: usize,
|
||||
pub max_cache_bytes: usize,
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// WRITE-BEHIND CACHE - Zero-latency uploads for small files
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Strategy:
|
||||
// 1. For files < 1MB, store in RAM and respond immediately (201 Created)
|
||||
// 2. Flush to disk asynchronously in background
|
||||
// 3. Serve reads from cache while pending flush
|
||||
// 4. On read miss, check if pending then serve from cache
|
||||
//
|
||||
// This gives users perceived ~0ms upload latency for small files
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use bytes::Bytes;
|
||||
|
||||
/// Maximum size for write-behind cache (files larger bypass cache)
|
||||
const WRITE_BEHIND_MAX_SIZE: usize = 1024 * 1024; // 1MB
|
||||
|
||||
/// Maximum total cache size in bytes
|
||||
const MAX_CACHE_SIZE: usize = 100 * 1024 * 1024; // 100MB total
|
||||
|
||||
/// Maximum time a file can stay pending before forced flush
|
||||
const MAX_PENDING_DURATION: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Flush check interval
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Entry in the write-behind cache
|
||||
#[derive(Clone)]
|
||||
pub struct PendingWrite {
|
||||
/// File content
|
||||
pub content: Bytes,
|
||||
/// Target path on disk
|
||||
pub target_path: PathBuf,
|
||||
/// When this entry was created
|
||||
pub created_at: Instant,
|
||||
/// File ID for tracking
|
||||
pub file_id: String,
|
||||
}
|
||||
|
||||
/// Statistics for monitoring
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WriteBehindStats {
|
||||
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,
|
||||
}
|
||||
|
||||
/// Write-Behind Cache for zero-latency small file uploads
|
||||
pub struct WriteBehindCache {
|
||||
/// Pending writes indexed by file ID
|
||||
pending: Arc<RwLock<HashMap<String, PendingWrite>>>,
|
||||
/// Current total size of pending data
|
||||
current_size: Arc<RwLock<usize>>,
|
||||
/// Channel to signal flush worker
|
||||
flush_tx: mpsc::Sender<FlushCommand>,
|
||||
/// Statistics
|
||||
stats: Arc<RwLock<WriteBehindStats>>,
|
||||
}
|
||||
|
||||
/// Commands for the flush worker
|
||||
enum FlushCommand {
|
||||
/// Flush a specific file
|
||||
FlushFile(String),
|
||||
/// Flush all pending files
|
||||
FlushAll,
|
||||
/// Shutdown the worker
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl WriteBehindCache {
|
||||
/// Create a new write-behind cache with background flush worker
|
||||
pub fn new() -> Arc<Self> {
|
||||
let (flush_tx, flush_rx) = mpsc::channel(1000);
|
||||
|
||||
let cache = Arc::new(Self {
|
||||
pending: Arc::new(RwLock::new(HashMap::new())),
|
||||
current_size: Arc::new(RwLock::new(0)),
|
||||
flush_tx,
|
||||
stats: Arc::new(RwLock::new(WriteBehindStats::default())),
|
||||
});
|
||||
|
||||
// Start the background flush worker
|
||||
let cache_clone = cache.clone();
|
||||
tokio::spawn(async move {
|
||||
cache_clone.flush_worker(flush_rx).await;
|
||||
});
|
||||
|
||||
// Start the periodic flush checker
|
||||
let cache_clone2 = cache.clone();
|
||||
tokio::spawn(async move {
|
||||
cache_clone2.periodic_flush_checker().await;
|
||||
});
|
||||
|
||||
tracing::info!("⚡ Write-Behind Cache initialized (max {}MB)", MAX_CACHE_SIZE / (1024 * 1024));
|
||||
|
||||
cache
|
||||
}
|
||||
|
||||
/// Check if a file size is eligible for write-behind caching
|
||||
#[inline]
|
||||
pub fn is_eligible(size: usize) -> bool {
|
||||
size <= WRITE_BEHIND_MAX_SIZE
|
||||
}
|
||||
|
||||
/// Put a file in the pending write cache
|
||||
/// Returns Ok(true) if cached, Ok(false) if cache is full
|
||||
pub async fn put_pending(
|
||||
&self,
|
||||
file_id: String,
|
||||
content: Bytes,
|
||||
target_path: PathBuf,
|
||||
) -> Result<bool, std::io::Error> {
|
||||
let content_size = content.len();
|
||||
|
||||
// Check if we have space
|
||||
{
|
||||
let current = *self.current_size.read().await;
|
||||
if current + content_size > MAX_CACHE_SIZE {
|
||||
tracing::debug!(
|
||||
"Write-behind cache full ({}/{}MB), bypassing for {}",
|
||||
current / (1024 * 1024),
|
||||
MAX_CACHE_SIZE / (1024 * 1024),
|
||||
file_id
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to pending
|
||||
let entry = PendingWrite {
|
||||
content,
|
||||
target_path,
|
||||
created_at: Instant::now(),
|
||||
file_id: file_id.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut pending = self.pending.write().await;
|
||||
let mut size = self.current_size.write().await;
|
||||
|
||||
// If replacing existing entry, adjust size
|
||||
if let Some(old) = pending.insert(file_id.clone(), entry) {
|
||||
*size -= old.content.len();
|
||||
}
|
||||
*size += content_size;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.pending_count += 1;
|
||||
stats.pending_bytes += content_size;
|
||||
}
|
||||
|
||||
// Signal flush worker (non-blocking)
|
||||
let _ = self.flush_tx.try_send(FlushCommand::FlushFile(file_id.clone()));
|
||||
|
||||
tracing::debug!("⚡ Cached pending write: {} ({} bytes)", file_id, content_size);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get content from cache if pending (for reads before flush completes)
|
||||
pub async fn get_pending(&self, file_id: &str) -> Option<Bytes> {
|
||||
let pending = self.pending.read().await;
|
||||
if let Some(entry) = pending.get(file_id) {
|
||||
// Update cache hit stats
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.cache_hits += 1;
|
||||
|
||||
tracing::debug!("⚡ Cache hit for pending file: {}", file_id);
|
||||
return Some(entry.content.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a file is pending flush
|
||||
pub async fn is_pending(&self, file_id: &str) -> bool {
|
||||
self.pending.read().await.contains_key(file_id)
|
||||
}
|
||||
|
||||
/// Force immediate flush of a specific file (for critical operations)
|
||||
pub async fn force_flush(&self, file_id: &str) -> Result<(), std::io::Error> {
|
||||
let entry = {
|
||||
let pending = self.pending.read().await;
|
||||
pending.get(file_id).cloned()
|
||||
};
|
||||
|
||||
if let Some(entry) = entry {
|
||||
self.flush_single(&entry.file_id, &entry).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush all pending writes immediately
|
||||
pub async fn flush_all(&self) -> Result<(), std::io::Error> {
|
||||
let _ = self.flush_tx.send(FlushCommand::FlushAll).await;
|
||||
|
||||
// Wait a bit for flush to complete
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shutdown the write-behind cache
|
||||
/// Flushes all pending writes before stopping the background worker
|
||||
pub async fn shutdown(&self) -> Result<(), std::io::Error> {
|
||||
tracing::info!("🛑 Shutting down write-behind cache...");
|
||||
|
||||
// First flush all pending writes
|
||||
self.flush_all().await?;
|
||||
|
||||
// Then signal the worker to stop
|
||||
let _ = self.flush_tx.send(FlushCommand::Shutdown).await;
|
||||
|
||||
// Give worker time to process shutdown
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
tracing::info!("✅ Write-behind cache shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current statistics
|
||||
pub async fn get_stats(&self) -> WriteBehindStats {
|
||||
self.stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// Background worker that handles actual disk writes
|
||||
async fn flush_worker(&self, mut rx: mpsc::Receiver<FlushCommand>) {
|
||||
tracing::info!("🔄 Write-behind flush worker started");
|
||||
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
match cmd {
|
||||
FlushCommand::FlushFile(file_id) => {
|
||||
// Small delay to batch nearby writes
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let entry = {
|
||||
let pending = self.pending.read().await;
|
||||
pending.get(&file_id).cloned()
|
||||
};
|
||||
|
||||
if let Some(entry) = entry {
|
||||
if let Err(e) = self.flush_single(&file_id, &entry).await {
|
||||
tracing::error!("Failed to flush {}: {}", file_id, e);
|
||||
// Keep in cache for retry
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
FlushCommand::FlushAll => {
|
||||
let entries: Vec<_> = {
|
||||
let pending = self.pending.read().await;
|
||||
pending.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
|
||||
};
|
||||
|
||||
for (file_id, entry) in entries {
|
||||
if let Err(e) = self.flush_single(&file_id, &entry).await {
|
||||
tracing::error!("Failed to flush {}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
FlushCommand::Shutdown => {
|
||||
tracing::info!("Write-behind flush worker shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a single file to disk
|
||||
async fn flush_single(&self, file_id: &str, entry: &PendingWrite) -> Result<(), std::io::Error> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = entry.target_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
// Write atomically using temp file + rename
|
||||
let temp_path = entry.target_path.with_extension("tmp");
|
||||
|
||||
{
|
||||
let mut file = fs::File::create(&temp_path).await?;
|
||||
file.write_all(&entry.content).await?;
|
||||
file.sync_all().await?;
|
||||
}
|
||||
|
||||
fs::rename(&temp_path, &entry.target_path).await?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let content_len = entry.content.len();
|
||||
|
||||
// Remove from pending
|
||||
{
|
||||
let mut pending = self.pending.write().await;
|
||||
let mut size = self.current_size.write().await;
|
||||
|
||||
if pending.remove(file_id).is_some() {
|
||||
*size = size.saturating_sub(content_len);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.pending_count = stats.pending_count.saturating_sub(1);
|
||||
stats.pending_bytes = stats.pending_bytes.saturating_sub(content_len);
|
||||
stats.total_writes += 1;
|
||||
stats.total_bytes_written += content_len as u64;
|
||||
|
||||
// Running average of flush time
|
||||
let flush_us = elapsed.as_micros() as u64;
|
||||
if stats.avg_flush_time_us == 0 {
|
||||
stats.avg_flush_time_us = flush_us;
|
||||
} else {
|
||||
stats.avg_flush_time_us = (stats.avg_flush_time_us * 9 + flush_us) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"💾 Flushed {} to disk ({} bytes in {:?})",
|
||||
file_id,
|
||||
content_len,
|
||||
elapsed
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Periodic checker for stale pending writes
|
||||
async fn periodic_flush_checker(&self) {
|
||||
let mut interval = tokio::time::interval(FLUSH_INTERVAL);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let stale_files: Vec<String> = {
|
||||
let pending = self.pending.read().await;
|
||||
pending
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.created_at.elapsed() > MAX_PENDING_DURATION)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
for file_id in stale_files {
|
||||
tracing::warn!("Forcing flush of stale pending file: {}", file_id);
|
||||
let _ = self.flush_tx.try_send(FlushCommand::FlushFile(file_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WriteBehindCache {
|
||||
fn default() -> Self {
|
||||
// Note: This creates a non-Arc version, prefer using new()
|
||||
let (flush_tx, _) = mpsc::channel(1);
|
||||
Self {
|
||||
pending: Arc::new(RwLock::new(HashMap::new())),
|
||||
current_size: Arc::new(RwLock::new(0)),
|
||||
flush_tx,
|
||||
stats: Arc::new(RwLock::new(WriteBehindStats::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_behind_basic() {
|
||||
let cache = WriteBehindCache::new();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let target = temp_dir.path().join("test.txt");
|
||||
|
||||
let content = Bytes::from("Hello, World!");
|
||||
|
||||
// Put in cache
|
||||
let cached = cache.put_pending(
|
||||
"test-id".to_string(),
|
||||
content.clone(),
|
||||
target.clone(),
|
||||
).await.unwrap();
|
||||
|
||||
assert!(cached);
|
||||
assert!(cache.is_pending("test-id").await);
|
||||
|
||||
// Should be readable from cache
|
||||
let cached_content = cache.get_pending("test-id").await.unwrap();
|
||||
assert_eq!(cached_content, content);
|
||||
|
||||
// Force flush
|
||||
cache.force_flush("test-id").await.unwrap();
|
||||
|
||||
// Should no longer be pending
|
||||
assert!(!cache.is_pending("test-id").await);
|
||||
|
||||
// File should exist on disk
|
||||
assert!(target.exists());
|
||||
let disk_content = std::fs::read(&target).unwrap();
|
||||
assert_eq!(disk_content, content.as_ref());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_eligibility() {
|
||||
// 500KB should be eligible
|
||||
assert!(WriteBehindCache::is_eligible(500 * 1024));
|
||||
|
||||
// 1MB exactly should be eligible
|
||||
assert!(WriteBehindCache::is_eligible(1024 * 1024));
|
||||
|
||||
// Over 1MB should not be eligible
|
||||
assert!(!WriteBehindCache::is_eligible(1024 * 1024 + 1));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{post, get, put},
|
||||
extract::{State, Json, Extension},
|
||||
extract::{State, Json},
|
||||
http::{StatusCode, HeaderMap, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
@@ -11,17 +11,25 @@ use crate::common::di::AppState;
|
||||
use crate::application::dtos::user_dto::{
|
||||
LoginDto, RegisterDto, UserDto, ChangePasswordDto, RefreshTokenDto, AuthResponseDto
|
||||
};
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use crate::interfaces::errors::AppError;
|
||||
|
||||
pub fn auth_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Rutas que NO requieren autenticación
|
||||
let public_routes = Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/refresh", post(refresh_token))
|
||||
.route("/status", get(get_system_status));
|
||||
|
||||
// Rutas que SÍ requieren autenticación - usamos route_layer para aplicar middleware
|
||||
// El middleware usará el state que se pase con .with_state() desde main.rs
|
||||
let protected_routes = Router::new()
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/change-password", put(change_password))
|
||||
.route("/logout", post(logout))
|
||||
.route("/logout", post(logout));
|
||||
|
||||
// Combinar rutas públicas y protegidas
|
||||
public_routes.merge(protected_routes)
|
||||
}
|
||||
|
||||
async fn register(
|
||||
@@ -265,69 +273,123 @@ async fn refresh_token(
|
||||
|
||||
async fn get_current_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(current_user): Extension<CurrentUser>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Normal process for all users
|
||||
let auth_service = state.auth_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
|
||||
|
||||
// Primero, intentamos actualizar las estadísticas de uso de almacenamiento
|
||||
// Si existe el servicio de uso de almacenamiento
|
||||
// Extraer y validar el token directamente
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Token de autorización no encontrado"))?;
|
||||
|
||||
// Validar el token y obtener claims
|
||||
let claims = auth_service.token_service.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(&format!("Token inválido: {}", e)))?;
|
||||
|
||||
let user_id = claims.sub;
|
||||
|
||||
// Primero, actualizar las estadísticas de uso de almacenamiento
|
||||
// IMPORTANTE: Esperamos el cálculo para devolver datos actualizados
|
||||
if let Some(storage_usage_service) = state.storage_usage_service.as_ref() {
|
||||
// Actualizamos el uso de almacenamiento en segundo plano
|
||||
// No bloqueamos la respuesta con esta actualización
|
||||
let user_id = current_user.id.clone();
|
||||
let storage_service = storage_usage_service.clone();
|
||||
|
||||
// Ejecutar asincronamente para no retrasar la respuesta
|
||||
tokio::spawn(async move {
|
||||
match storage_service.update_user_storage_usage(&user_id).await {
|
||||
Ok(usage) => {
|
||||
tracing::info!("Updated storage usage for user {}: {} bytes", user_id, usage);
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to update storage usage for user {}: {}", user_id, e);
|
||||
}
|
||||
// Calcular storage de forma síncrona (esperamos el resultado)
|
||||
match storage_usage_service.update_user_storage_usage(&user_id).await {
|
||||
Ok(usage) => {
|
||||
tracing::info!("Updated storage usage for user {}: {} bytes", user_id, usage);
|
||||
},
|
||||
Err(e) => {
|
||||
// Solo log de warning, no fallar la petición completa
|
||||
tracing::warn!("Failed to update storage usage for user {}: {}", user_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener los datos del usuario (que puede tener valores de almacenamiento desactualizados)
|
||||
let user = auth_service.auth_application_service.get_user_by_id(¤t_user.id).await?;
|
||||
// Ahora obtener los datos del usuario CON el almacenamiento actualizado
|
||||
let user = auth_service.auth_application_service.get_user_by_id(&user_id).await?;
|
||||
|
||||
Ok((StatusCode::OK, Json(user)))
|
||||
}
|
||||
|
||||
async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(current_user): Extension<CurrentUser>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<ChangePasswordDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state.auth_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
|
||||
|
||||
auth_service.auth_application_service.change_password(¤t_user.id, dto).await?;
|
||||
// Extraer y validar el token directamente
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Token de autorización no encontrado"))?;
|
||||
|
||||
// Validar el token y obtener claims
|
||||
let claims = auth_service.token_service.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(&format!("Token inválido: {}", e)))?;
|
||||
|
||||
auth_service.auth_application_service.change_password(&claims.sub, dto).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(current_user): Extension<CurrentUser>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state.auth_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
|
||||
|
||||
// Extract refresh token from request
|
||||
let refresh_token = headers
|
||||
// Extraer y validar el token directamente
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Token de refresco no encontrado"))?;
|
||||
.ok_or_else(|| AppError::unauthorized("Token de autorización no encontrado"))?;
|
||||
|
||||
auth_service.auth_application_service.logout(¤t_user.id, refresh_token).await?;
|
||||
// Validar el token y obtener claims
|
||||
let claims = auth_service.token_service.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(&format!("Token inválido: {}", e)))?;
|
||||
|
||||
// Use access token for logout (we don't have refresh token in headers)
|
||||
auth_service.auth_application_service.logout(&claims.sub, token).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Get system status - returns whether admin is configured
|
||||
/// This is a public endpoint used to determine if setup is needed
|
||||
#[derive(serde::Serialize)]
|
||||
struct SystemStatus {
|
||||
/// Whether the system has been set up with an admin
|
||||
initialized: bool,
|
||||
/// Number of admin users in the system
|
||||
admin_count: i64,
|
||||
/// Whether registration is allowed (only if admin exists)
|
||||
registration_allowed: bool,
|
||||
}
|
||||
|
||||
async fn get_system_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state.auth_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?;
|
||||
|
||||
// Count admin users to determine if system is initialized
|
||||
let admin_count = auth_service.auth_application_service.count_admin_users().await
|
||||
.unwrap_or(0);
|
||||
|
||||
let status = SystemStatus {
|
||||
initialized: admin_count > 0,
|
||||
admin_count,
|
||||
registration_allowed: admin_count > 0, // Only allow registration if admin exists
|
||||
};
|
||||
|
||||
tracing::info!("System status check: initialized={}, admin_count={}", status.initialized, status.admin_count);
|
||||
|
||||
Ok((StatusCode::OK, Json(status)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Chunked Upload Handler - TUS-like Protocol Endpoints
|
||||
//!
|
||||
//! Provides HTTP endpoints for resumable, parallel chunk uploads:
|
||||
//! - POST /api/uploads → Create upload session
|
||||
//! - PATCH /api/uploads/:id → Upload a chunk
|
||||
//! - HEAD /api/uploads/:id → Get upload status
|
||||
//! - POST /api/uploads/:id/complete → Assemble and finalize
|
||||
//! - DELETE /api/uploads/:id → Cancel upload
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
http::{StatusCode, header, HeaderMap},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::infrastructure::services::chunked_upload_service::DEFAULT_CHUNK_SIZE;
|
||||
|
||||
/// Request body for creating an upload session
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUploadRequest {
|
||||
pub filename: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub total_size: u64,
|
||||
pub chunk_size: Option<usize>,
|
||||
}
|
||||
|
||||
/// Query params for chunk upload
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChunkUploadParams {
|
||||
pub chunk_index: usize,
|
||||
pub checksum: Option<String>,
|
||||
}
|
||||
|
||||
/// Final response after completing upload
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CompleteUploadResponse {
|
||||
pub file_id: String,
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Chunked Upload Handler
|
||||
pub struct ChunkedUploadHandler;
|
||||
|
||||
impl ChunkedUploadHandler {
|
||||
/// POST /api/uploads - Create a new upload session
|
||||
///
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "filename": "large-video.mp4",
|
||||
/// "folder_id": "optional-folder-id",
|
||||
/// "content_type": "video/mp4",
|
||||
/// "total_size": 104857600,
|
||||
/// "chunk_size": 5242880
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Response:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "upload_id": "uuid",
|
||||
/// "chunk_size": 5242880,
|
||||
/// "total_chunks": 20,
|
||||
/// "expires_at": 86400
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn create_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateUploadRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let chunked_service = &state.core.chunked_upload_service;
|
||||
|
||||
// Validate request
|
||||
if request.filename.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "Filename is required"
|
||||
}))).into_response();
|
||||
}
|
||||
|
||||
if request.total_size == 0 {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "Total size must be greater than 0"
|
||||
}))).into_response();
|
||||
}
|
||||
|
||||
// Validate chunk size if provided
|
||||
let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
|
||||
if chunk_size < 1024 * 1024 {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "Chunk size must be at least 1MB"
|
||||
}))).into_response();
|
||||
}
|
||||
|
||||
let content_type = request.content_type
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
match chunked_service.create_session(
|
||||
request.filename,
|
||||
request.folder_id,
|
||||
content_type,
|
||||
request.total_size,
|
||||
Some(chunk_size),
|
||||
).await {
|
||||
Ok(response) => {
|
||||
(StatusCode::CREATED, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create upload session: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": e
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/uploads/:upload_id - Upload a chunk
|
||||
///
|
||||
/// Query params:
|
||||
/// - chunk_index: The index of the chunk (0-based)
|
||||
/// - checksum: Optional MD5 checksum for verification
|
||||
///
|
||||
/// Body: Raw bytes of the chunk
|
||||
pub async fn upload_chunk(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(upload_id): Path<String>,
|
||||
Query(params): Query<ChunkUploadParams>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let chunked_service = &state.core.chunked_upload_service;
|
||||
|
||||
// Extract checksum from header or query param
|
||||
let checksum = params.checksum.or_else(|| {
|
||||
headers.get("Content-MD5")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
match chunked_service.upload_chunk(
|
||||
&upload_id,
|
||||
params.chunk_index,
|
||||
body,
|
||||
checksum,
|
||||
).await {
|
||||
Ok(response) => {
|
||||
let mut resp = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("Upload-Offset", response.bytes_received.to_string())
|
||||
.header("Upload-Progress", format!("{:.2}", response.progress * 100.0));
|
||||
|
||||
if response.is_complete {
|
||||
resp = resp.header("Upload-Complete", "true");
|
||||
}
|
||||
|
||||
resp.body(axum::body::Body::from(serde_json::to_string(&response).unwrap()))
|
||||
.unwrap()
|
||||
.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
|
||||
};
|
||||
|
||||
(status, Json(serde_json::json!({
|
||||
"error": e
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HEAD /api/uploads/:upload_id - Get upload status
|
||||
///
|
||||
/// Returns upload progress and pending chunks
|
||||
pub async fn get_upload_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(upload_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let chunked_service = &state.core.chunked_upload_service;
|
||||
|
||||
match chunked_service.get_status(&upload_id).await {
|
||||
Ok(status) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("Upload-Offset", status.bytes_received.to_string())
|
||||
.header("Upload-Length", status.total_size.to_string())
|
||||
.header("Upload-Progress", format!("{:.2}", status.progress * 100.0))
|
||||
.header("Upload-Chunks-Total", status.total_chunks.to_string())
|
||||
.header("Upload-Chunks-Complete", status.completed_chunks.to_string())
|
||||
.body(axum::body::Body::from(serde_json::to_string(&status).unwrap()))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({
|
||||
"error": e
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/uploads/:upload_id/complete - Finalize upload
|
||||
///
|
||||
/// Assembles all chunks into the final file and creates the file record
|
||||
pub async fn complete_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(upload_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let chunked_service = &state.core.chunked_upload_service;
|
||||
let file_service = &state.applications.file_service_concrete;
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
return (status, Json(serde_json::json!({
|
||||
"error": e
|
||||
}))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Read assembled file and create final file record
|
||||
let file_data = match tokio::fs::read(&assembled_path).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read assembled file: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": format!("Failed to read assembled file: {}", e)
|
||||
}))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Upload via normal service (this handles path resolution, metadata, etc.)
|
||||
match file_service.upload_file_from_bytes(
|
||||
filename.clone(),
|
||||
folder_id.clone(),
|
||||
content_type,
|
||||
file_data,
|
||||
).await {
|
||||
Ok(file) => {
|
||||
// Cleanup session
|
||||
let _ = chunked_service.finalize_upload(&upload_id).await;
|
||||
|
||||
tracing::info!(
|
||||
"✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)",
|
||||
filename, file.id, total_size
|
||||
);
|
||||
|
||||
(StatusCode::CREATED, Json(CompleteUploadResponse {
|
||||
file_id: file.id,
|
||||
filename: file.name,
|
||||
size: total_size,
|
||||
path: file.path,
|
||||
})).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create file from assembled upload: {:?}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": format!("Failed to create file: {:?}", e)
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/uploads/:upload_id - Cancel upload
|
||||
///
|
||||
/// Cancels an in-progress upload and cleans up temp files
|
||||
pub async fn cancel_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(upload_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let chunked_service = &state.core.chunked_upload_service;
|
||||
|
||||
match chunked_service.cancel_upload(&upload_id).await {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": e
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Multipart},
|
||||
http::{StatusCode, header, Response},
|
||||
response::IntoResponse,
|
||||
body::Body,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::infrastructure::services::dedup_service::DedupResult;
|
||||
|
||||
/// Global application state for dependency injection
|
||||
type GlobalState = AppState;
|
||||
|
||||
/// Response for hash check endpoint
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HashCheckResponse {
|
||||
/// Whether a blob with this hash already exists
|
||||
pub exists: bool,
|
||||
/// The SHA-256 hash that was checked
|
||||
pub hash: String,
|
||||
/// If exists, the size of the existing blob
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub existing_size: Option<u64>,
|
||||
/// If exists, the number of references to this blob
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ref_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Response for upload with dedup endpoint
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DedupUploadResponse {
|
||||
/// Whether this was a new file or an existing one
|
||||
pub is_new: bool,
|
||||
/// The SHA-256 hash of the content
|
||||
pub hash: String,
|
||||
/// The size of the content in bytes
|
||||
pub size: u64,
|
||||
/// Bytes saved by deduplication (0 if new file)
|
||||
pub bytes_saved: u64,
|
||||
/// Current reference count for this blob
|
||||
pub ref_count: u32,
|
||||
}
|
||||
|
||||
/// Response for dedup stats endpoint
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatsResponse {
|
||||
/// Total number of unique blobs stored
|
||||
pub unique_blobs: u64,
|
||||
/// Total number of references (files pointing to blobs)
|
||||
pub total_references: u64,
|
||||
/// Total bytes saved by deduplication
|
||||
pub bytes_saved: u64,
|
||||
/// Total logical bytes (what users think they have)
|
||||
pub total_logical_bytes: u64,
|
||||
/// Total physical bytes (actual disk usage)
|
||||
pub total_physical_bytes: u64,
|
||||
/// Deduplication ratio (logical / physical)
|
||||
pub dedup_ratio: f64,
|
||||
/// Percentage of storage saved
|
||||
pub savings_percentage: f64,
|
||||
}
|
||||
|
||||
/// Handler for deduplication-related endpoints
|
||||
///
|
||||
/// Provides endpoints for:
|
||||
/// - Checking if content already exists (by hash)
|
||||
/// - Uploading files with automatic deduplication
|
||||
/// - Getting deduplication statistics
|
||||
pub struct DedupHandler;
|
||||
|
||||
impl DedupHandler {
|
||||
/// Check if a blob with the given hash already exists
|
||||
///
|
||||
/// This endpoint allows clients to check if uploading a file is necessary
|
||||
/// by pre-computing the hash client-side and checking against the server.
|
||||
///
|
||||
/// GET /api/dedup/check/{hash}
|
||||
pub async fn check_hash(
|
||||
State(state): State<GlobalState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Validate hash format (SHA-256 = 64 hex chars)
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Invalid hash format. Expected SHA-256 (64 hex characters)"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match dedup.get_blob_metadata(&hash).await {
|
||||
Some(metadata) => {
|
||||
let response = HashCheckResponse {
|
||||
exists: true,
|
||||
hash,
|
||||
existing_size: Some(metadata.size),
|
||||
ref_count: Some(metadata.ref_count),
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&response).unwrap()))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
None => {
|
||||
let response = HashCheckResponse {
|
||||
exists: false,
|
||||
hash,
|
||||
existing_size: None,
|
||||
ref_count: None,
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&response).unwrap()))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload content with automatic deduplication
|
||||
///
|
||||
/// This endpoint calculates the SHA-256 hash of the uploaded content
|
||||
/// and either creates a new blob or increments the reference count
|
||||
/// of an existing blob.
|
||||
///
|
||||
/// POST /api/dedup/upload
|
||||
///
|
||||
/// Returns information about whether the content was new or deduplicated.
|
||||
pub async fn upload_with_dedup(
|
||||
State(state): State<GlobalState>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Process multipart form
|
||||
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if name == "file" {
|
||||
let content_type = field.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
// Collect all chunks
|
||||
let mut chunks: Vec<Bytes> = Vec::new();
|
||||
let mut total_size: usize = 0;
|
||||
let mut field = field;
|
||||
|
||||
while let Ok(Some(chunk)) = field.chunk().await {
|
||||
total_size += chunk.len();
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Empty file not allowed"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Combine chunks
|
||||
let data: Vec<u8> = 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
|
||||
};
|
||||
|
||||
// Store with deduplication
|
||||
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),
|
||||
};
|
||||
|
||||
let metadata = dedup.get_blob_metadata(result.hash()).await;
|
||||
|
||||
let response = DedupUploadResponse {
|
||||
is_new,
|
||||
hash: result.hash().to_string(),
|
||||
size: result.size(),
|
||||
bytes_saved,
|
||||
ref_count: metadata.map(|m| m.ref_count).unwrap_or(1),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"🔗 Dedup upload: hash={}, new={}, saved={}",
|
||||
result.hash(),
|
||||
is_new,
|
||||
bytes_saved
|
||||
);
|
||||
|
||||
return Response::builder()
|
||||
.status(if is_new { StatusCode::CREATED } else { StatusCode::OK })
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&response).unwrap()))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("❌ Dedup upload failed: {}", e);
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(r#"{{"error": "Upload failed: {}"}}"#, e)))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "No file field found in multipart form"}"#))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Get deduplication statistics
|
||||
///
|
||||
/// GET /api/dedup/stats
|
||||
///
|
||||
/// Returns comprehensive statistics about the deduplication system including:
|
||||
/// - Number of unique blobs
|
||||
/// - Total references
|
||||
/// - Bytes saved
|
||||
/// - Deduplication ratio
|
||||
pub async fn get_stats(
|
||||
State(state): State<GlobalState>,
|
||||
) -> impl IntoResponse {
|
||||
let dedup = &state.core.dedup_service;
|
||||
let stats = dedup.get_stats().await;
|
||||
|
||||
// Calculate savings percentage
|
||||
let savings_pct = if stats.total_bytes_referenced > 0 {
|
||||
(stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let response = StatsResponse {
|
||||
unique_blobs: stats.total_blobs,
|
||||
total_references: stats.dedup_hits + stats.total_blobs, // Approximation
|
||||
bytes_saved: stats.bytes_saved,
|
||||
total_logical_bytes: stats.total_bytes_referenced,
|
||||
total_physical_bytes: stats.total_bytes_stored,
|
||||
dedup_ratio: stats.dedup_ratio,
|
||||
savings_percentage: savings_pct,
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&response).unwrap()))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Retrieve content by hash
|
||||
///
|
||||
/// GET /api/dedup/blob/{hash}
|
||||
///
|
||||
/// Returns the raw content of a blob identified by its SHA-256 hash.
|
||||
/// Useful for retrieving deduplicated content.
|
||||
pub async fn get_blob(
|
||||
State(state): State<GlobalState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Validate hash format
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Invalid hash format"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Get metadata first for content-type
|
||||
let metadata = dedup.get_blob_metadata(&hash).await;
|
||||
let content_type = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.content_type.clone())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
match dedup.read_blob_bytes(&hash).await {
|
||||
Ok(content) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_LENGTH, content.len().to_string())
|
||||
.header("X-Dedup-Hash", &hash)
|
||||
.body(Body::from(content))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Blob not found"}"#))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a reference to a blob
|
||||
///
|
||||
/// DELETE /api/dedup/blob/{hash}
|
||||
///
|
||||
/// Decrements the reference count for a blob. If the reference count
|
||||
/// reaches zero, the blob is deleted from storage.
|
||||
pub async fn remove_reference(
|
||||
State(state): State<GlobalState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Validate hash format
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Invalid hash format"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match dedup.remove_reference(&hash).await {
|
||||
Ok(deleted) => {
|
||||
let message = if deleted {
|
||||
format!(r#"{{"success": true, "deleted": true, "message": "Blob {} was deleted (ref_count reached 0)"}}"#, hash)
|
||||
} else {
|
||||
format!(r#"{{"success": true, "deleted": false, "message": "Reference removed from blob {}"}}"#, hash)
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(message))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(r#"{{"error": "{}"}}"#, e)))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Force recalculation of statistics from disk
|
||||
///
|
||||
/// POST /api/dedup/recalculate
|
||||
///
|
||||
/// Verifies integrity and returns current statistics.
|
||||
/// Useful for health checks and auditing.
|
||||
pub async fn recalculate_stats(
|
||||
State(state): State<GlobalState>,
|
||||
) -> impl IntoResponse {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Verify integrity first
|
||||
match dedup.verify_integrity().await {
|
||||
Ok(issues) => {
|
||||
if !issues.is_empty() {
|
||||
tracing::warn!("Dedup integrity issues found: {:?}", issues);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(r#"{{"error": "Verification failed: {}"}}"#, e)))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let stats = dedup.get_stats().await;
|
||||
|
||||
// Calculate savings percentage
|
||||
let savings_pct = if stats.total_bytes_referenced > 0 {
|
||||
(stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let response = StatsResponse {
|
||||
unique_blobs: stats.total_blobs,
|
||||
total_references: stats.dedup_hits + stats.total_blobs,
|
||||
bytes_saved: stats.bytes_saved,
|
||||
total_logical_bytes: stats.total_bytes_referenced,
|
||||
total_physical_bytes: stats.total_bytes_stored,
|
||||
dedup_ratio: stats.dedup_ratio,
|
||||
savings_percentage: savings_pct,
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&response).unwrap()))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ pub mod favorites_handler;
|
||||
pub mod recent_handler;
|
||||
pub mod webdav_handler;
|
||||
pub mod caldav_handler;
|
||||
pub mod chunked_upload_handler;
|
||||
pub mod dedup_handler;
|
||||
|
||||
/// Tipo de resultado para controladores de API
|
||||
pub type ApiResult<T> = Result<T, (axum::http::StatusCode, String)>;
|
||||
@@ -31,6 +31,7 @@ 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::batch_handler::{
|
||||
self, BatchHandlerState
|
||||
@@ -83,13 +84,54 @@ pub fn create_api_routes(
|
||||
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 {
|
||||
@@ -239,13 +281,13 @@ pub fn create_api_routes(
|
||||
// Create file routes for basic operations and trash-enabled delete
|
||||
let basic_file_router = Router::new()
|
||||
.route("/", get(|
|
||||
State(service): State<Arc<FileService>>,
|
||||
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);
|
||||
// Pass the service directly to the handler
|
||||
let service = &state.applications.file_service_concrete;
|
||||
match service.list_files(folder_id).await {
|
||||
Ok(files) => {
|
||||
tracing::info!("Found {} files", files.len());
|
||||
@@ -259,9 +301,58 @@ pub fn create_api_routes(
|
||||
}
|
||||
}
|
||||
}))
|
||||
.route("/upload", post(FileHandler::upload_file))
|
||||
.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("/{id}", get(FileHandler::download_file))
|
||||
.with_state(file_service.clone());
|
||||
.route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
// Let's create a router for file operations with trash support
|
||||
let file_operations_router = Router::new()
|
||||
@@ -379,10 +470,31 @@ pub fn create_api_routes(
|
||||
} else {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Create routes for chunked uploads (large files >10MB)
|
||||
let chunked_upload_router = Router::new()
|
||||
.route("/", post(ChunkedUploadHandler::create_upload))
|
||||
.route("/{upload_id}", axum::routing::patch(ChunkedUploadHandler::upload_chunk))
|
||||
.route("/{upload_id}", axum::routing::head(ChunkedUploadHandler::get_upload_status))
|
||||
.route("/{upload_id}/complete", post(ChunkedUploadHandler::complete_upload))
|
||||
.route("/{upload_id}", delete(ChunkedUploadHandler::cancel_upload))
|
||||
.with_state(Arc::new(app_state.clone()));
|
||||
|
||||
// Create routes for deduplication endpoints
|
||||
let dedup_router = Router::new()
|
||||
.route("/check/{hash}", get(super::handlers::dedup_handler::DedupHandler::check_hash))
|
||||
.route("/upload", post(super::handlers::dedup_handler::DedupHandler::upload_with_dedup))
|
||||
.route("/stats", get(super::handlers::dedup_handler::DedupHandler::get_stats))
|
||||
.route("/blob/{hash}", get(super::handlers::dedup_handler::DedupHandler::get_blob))
|
||||
.route("/blob/{hash}", delete(super::handlers::dedup_handler::DedupHandler::remove_reference))
|
||||
.route("/recalculate", post(super::handlers::dedup_handler::DedupHandler::recalculate_stats))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
let mut router = Router::new()
|
||||
.nest("/folders", folders_router)
|
||||
.nest("/files", files_router)
|
||||
.nest("/uploads", chunked_upload_router)
|
||||
.nest("/dedup", dedup_router)
|
||||
.nest("/batch", batch_router)
|
||||
.nest("/search", search_router)
|
||||
.nest("/shares", share_router)
|
||||
|
||||
@@ -74,7 +74,7 @@ pub async fn get_auth_user(req: &Request<Body>) -> Result<AuthUser, AuthError> {
|
||||
|
||||
// Middleware de autenticación simplificado - solo valida si existe un token
|
||||
pub async fn auth_middleware(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
@@ -117,20 +117,68 @@ pub async fn auth_middleware(
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Process normal token
|
||||
// Process normal token - try to validate it using JWT service
|
||||
tracing::info!("Processing token: {}", token_str.chars().take(8).collect::<String>() + "...");
|
||||
|
||||
// For regular tokens, create a test user (this will be replaced with real validation)
|
||||
let current_user = CurrentUser {
|
||||
id: "test-user-id".to_string(),
|
||||
username: "test-user".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
role: "user".to_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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir usuario a la request
|
||||
request.extensions_mut().insert(current_user);
|
||||
return Ok(next.run(request).await);
|
||||
// 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);
|
||||
let current_user = CurrentUser {
|
||||
id: token_data.claims.sub,
|
||||
username: token_data.claims.username,
|
||||
email: token_data.claims.email,
|
||||
role: token_data.claims.role,
|
||||
};
|
||||
request.extensions_mut().insert(current_user);
|
||||
return Ok(next.run(request).await);
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Fallback token decode failed: {}", e);
|
||||
return Err(AuthError::InvalidToken(format!("Token inválido: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si hay un indicador para evitar redirección, permitir el acceso sin token
|
||||
|
||||
+77
-1
@@ -239,6 +239,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map_err(|e| domain::repositories::file_repository::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>>,
|
||||
) -> domain::repositories::file_repository::FileRepositoryResult<domain::entities::file::File> {
|
||||
self.repo.save_file_from_stream(name, folder_id, content_type, stream)
|
||||
.await
|
||||
.map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn save_file_with_id(
|
||||
&self,
|
||||
_id: String,
|
||||
@@ -284,6 +296,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> domain::repositories::file_repository::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| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, id: &str) -> domain::repositories::file_repository::FileRepositoryResult<bytes::Bytes> {
|
||||
self.repo.get_file_mmap(id)
|
||||
.await
|
||||
.map_err(|e| domain::repositories::file_repository::FileRepositoryError::Other(format!("{}", e)))
|
||||
}
|
||||
|
||||
async fn move_file(&self, id: &str, target_folder_id: Option<String>) -> domain::repositories::file_repository::FileRepositoryResult<domain::entities::file::File> {
|
||||
self.repo.move_file(id, target_folder_id)
|
||||
.await
|
||||
@@ -549,13 +578,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
|
||||
// Create thumbnail service
|
||||
let thumbnail_service = Arc::new(
|
||||
infrastructure::services::thumbnail_service::ThumbnailService::new(
|
||||
&storage_path,
|
||||
5000, // max 5000 thumbnails en cache
|
||||
100 * 1024 * 1024, // max 100MB de cache
|
||||
)
|
||||
);
|
||||
// Note: thumbnail directories will be initialized when first used
|
||||
|
||||
// Create write-behind cache for zero-latency small file uploads
|
||||
let write_behind_cache = infrastructure::services::write_behind_cache::WriteBehindCache::new();
|
||||
|
||||
// Create chunked upload service for large file uploads (>10MB)
|
||||
let chunked_upload_service = Arc::new(
|
||||
infrastructure::services::chunked_upload_service::ChunkedUploadService::new(
|
||||
storage_path.join(".uploads")
|
||||
)
|
||||
);
|
||||
|
||||
// Create image transcode service for automatic WebP conversion
|
||||
let image_transcode_service = Arc::new(
|
||||
infrastructure::services::image_transcode_service::ImageTranscodeService::new(
|
||||
&storage_path,
|
||||
2000, // max 2000 transcoded images in cache
|
||||
50 * 1024 * 1024, // max 50MB memory cache
|
||||
)
|
||||
);
|
||||
// Initialize transcode service directories
|
||||
if let Err(e) = image_transcode_service.initialize().await {
|
||||
tracing::warn!("Failed to initialize image transcode service: {}", e);
|
||||
}
|
||||
|
||||
// Create deduplication service for content-addressable storage
|
||||
let dedup_service = Arc::new(
|
||||
infrastructure::services::dedup_service::DedupService::new(&storage_path)
|
||||
);
|
||||
if let Err(e) = dedup_service.initialize().await {
|
||||
tracing::warn!("Failed to initialize dedup service: {}", e);
|
||||
}
|
||||
|
||||
// Create AppState for DI container
|
||||
let core_services = common::di::CoreServices {
|
||||
path_service: path_service.clone(),
|
||||
cache_manager: Arc::new(infrastructure::services::cache_manager::StorageCacheManager::default()),
|
||||
file_content_cache: Arc::new(infrastructure::services::file_content_cache::FileContentCache::default()),
|
||||
id_mapping_service: base_id_mapping_service.clone(),
|
||||
file_id_mapping_service: file_id_mapping_service.clone(),
|
||||
id_mapping_optimizer: id_mapping_optimizer.clone(),
|
||||
thumbnail_service: thumbnail_service.clone(),
|
||||
write_behind_cache: write_behind_cache.clone(),
|
||||
chunked_upload_service: chunked_upload_service.clone(),
|
||||
image_transcode_service: image_transcode_service.clone(),
|
||||
dedup_service: dedup_service.clone(),
|
||||
config: config.clone(),
|
||||
};
|
||||
|
||||
@@ -754,7 +830,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Add auth routes if auth is enabled
|
||||
if config.features.enable_auth && auth_services.is_some() {
|
||||
// Create auth routes with app state
|
||||
// Create auth routes with app state (no middleware needed - handlers extract token directly)
|
||||
let auth_router = auth_routes().with_state(app_state.clone());
|
||||
|
||||
// Add auth routes at /api/auth
|
||||
|
||||
@@ -196,9 +196,107 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Language selector panel styles */
|
||||
.language-selector-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.language-subtitle {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.language-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.language-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.language-option:hover {
|
||||
border-color: #ff5e3a;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.language-option.selected {
|
||||
border-color: #ff5e3a;
|
||||
background-color: #fff5f3;
|
||||
}
|
||||
|
||||
.language-option input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.language-radio {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 50%;
|
||||
margin-right: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.language-option.selected .language-radio {
|
||||
border-color: #ff5e3a;
|
||||
}
|
||||
|
||||
.language-radio::after {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #ff5e3a;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.language-option.selected .language-radio::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 28px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.language-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.language-native {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-panel {
|
||||
width: 90%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.language-option {
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
+516
-47
@@ -13,6 +13,59 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
/* Global select styling */
|
||||
select {
|
||||
padding: 8px 32px 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
color: #2d3748;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23718096' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #ff5e3a;
|
||||
box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.1);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
@@ -131,17 +184,20 @@ body {
|
||||
/* Top bar */
|
||||
.top-bar {
|
||||
height: 70px;
|
||||
min-height: 70px;
|
||||
max-height: 70px;
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30px;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex-grow: 1;
|
||||
max-width: 500px;
|
||||
max-width: 600px;
|
||||
position: relative;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
@@ -150,40 +206,78 @@ body {
|
||||
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 10px 15px 10px 40px;
|
||||
border-radius: 50px;
|
||||
border: none;
|
||||
background-color: #f0f3f7;
|
||||
padding: 12px 50px 12px 44px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background-color: #f8fafc;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
height: 46px;
|
||||
color: #1a202c;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-container input:hover {
|
||||
border-color: #cbd5e0;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
outline: none;
|
||||
border-color: #ff5e3a;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(255, 94, 58, 0.1);
|
||||
}
|
||||
|
||||
.search-container input::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #8895a7;
|
||||
color: #a0aec0;
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-container input:focus + .search-icon,
|
||||
.search-container:focus-within .search-icon {
|
||||
color: #ff5e3a;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
background-color: #ff5e3a;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(255, 94, 58, 0.3);
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
background-color: #e64a29;
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(255, 94, 58, 0.4);
|
||||
}
|
||||
|
||||
.search-button:active {
|
||||
transform: translateY(-50%) scale(0.98);
|
||||
}
|
||||
|
||||
.search-button i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Styles for search results */
|
||||
@@ -217,36 +311,144 @@ body {
|
||||
.user-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin-left: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition: color 0.2s;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #ef4444;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.logout-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Language Selector - Custom Dropdown */
|
||||
.language-selector {
|
||||
position: relative;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.language-selector-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background-color: #f0f3f7;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.language-selector-toggle:hover {
|
||||
background-color: #e2e8f0;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.language-selector-toggle i {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.language-selector-toggle .lang-code {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.language-selector-toggle .dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: #718096;
|
||||
transition: transform 0.2s ease;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.language-selector.open .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.language-selector-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e2e8f0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.language-selector.open .language-selector-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.language-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.language-option:hover {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
.language-option.active {
|
||||
background-color: #fff5f3;
|
||||
color: #ff5e3a;
|
||||
}
|
||||
|
||||
.language-selector {
|
||||
margin-right: 15px;
|
||||
padding: 5px 12px;
|
||||
background-color: #f0f3f7;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.language-option .lang-flag {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.language-selector::after {
|
||||
content: "▼";
|
||||
font-size: 8px;
|
||||
margin-left: 5px;
|
||||
color: #718096;
|
||||
.language-option .lang-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.language-option .lang-check {
|
||||
color: #ff5e3a;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.language-option.active .lang-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -265,7 +467,8 @@ body {
|
||||
.content-area {
|
||||
flex-grow: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -283,68 +486,144 @@ body {
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 50px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn i {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #ff5e3a;
|
||||
background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(255, 94, 58, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 94, 58, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 10px rgba(255, 94, 58, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #f0f3f7;
|
||||
color: #333;
|
||||
background-color: #f8fafc;
|
||||
color: #4a5568;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #edf2f7;
|
||||
border-color: #cbd5e0;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* View Toggle Buttons */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
background-color: #f0f3f7;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background-color: #f0f3f7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 8px 15px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background-color: #e6e6e6;
|
||||
background-color: white;
|
||||
color: #ff5e3a;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.toggle-btn i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Files grid */
|
||||
.files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 240px));
|
||||
gap: 20px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.08);
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.file-card.dragging {
|
||||
@@ -888,6 +1167,176 @@ body {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* Modern Modal Overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
width: 420px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
transition: transform 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-overlay.active .modal-container {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-icon i {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #f0f3f7;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-body label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #1a202c;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: #ff5e3a;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.1);
|
||||
}
|
||||
|
||||
.modal-input::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-footer .btn-secondary {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.modal-footer .btn-secondary:hover {
|
||||
background: #f0f3f7;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.modal-footer .btn-primary {
|
||||
background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(255, 94, 58, 0.3);
|
||||
}
|
||||
|
||||
.modal-footer .btn-primary:hover {
|
||||
box-shadow: 0 4px 12px rgba(255, 94, 58, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-footer .btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Dialogs (Rename, Move, Share) */
|
||||
.rename-dialog, .share-dialog {
|
||||
position: fixed;
|
||||
@@ -1104,14 +1553,34 @@ header {
|
||||
.filter-group label {
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
padding: 8px 32px 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
color: #2d3748;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23718096' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-group select:hover {
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #ff5e3a;
|
||||
box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.1);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
|
||||
+37
-9
@@ -17,6 +17,7 @@
|
||||
<!-- Scripts -->
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/languageSelector.js"></script>
|
||||
<script src="/js/modal.js"></script>
|
||||
<script src="/js/ui.js"></script>
|
||||
<script src="/js/contextMenus.js"></script>
|
||||
<script src="/js/fileOperations.js"></script>
|
||||
@@ -79,11 +80,11 @@
|
||||
</div>
|
||||
|
||||
<div class="storage-container">
|
||||
<div class="storage-title">Almacenamiento</div>
|
||||
<div class="storage-title" data-i18n="storage.title">Storage</div>
|
||||
<div class="storage-bar">
|
||||
<div class="storage-fill"></div>
|
||||
</div>
|
||||
<div class="storage-info">Calculando...</div>
|
||||
<div class="storage-info" data-i18n="storage.calculating">Calculating...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,14 +94,16 @@
|
||||
<div class="top-bar">
|
||||
<div class="search-container">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" data-i18n-placeholder="actions.search" placeholder="Buscar archivos, carpetas...">
|
||||
<button id="search-button" class="search-button"><i class="fas fa-search"></i></button>
|
||||
<input type="text" id="search-input" data-i18n-placeholder="actions.search" placeholder="Buscar archivos, carpetas...">
|
||||
<button id="search-button" class="search-button" data-i18n-title="actions.search_btn" title="Search">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-controls">
|
||||
<div id="language-selector" class="language-selector">ES</div>
|
||||
<div class="user-avatar">MR</div>
|
||||
<div id="logout-btn" class="logout-btn" title="Cerrar sesión">
|
||||
<div id="language-selector"></div>
|
||||
<div class="user-avatar" id="user-avatar">AD</div>
|
||||
<div id="logout-btn" class="logout-btn" data-i18n-title="actions.logout" title="Log out">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,10 +115,12 @@
|
||||
<div class="actions-bar">
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" id="upload-btn">
|
||||
<i class="fas fa-upload" style="margin-right: 5px;"></i> <span data-i18n="actions.upload">Subir</span>
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<span data-i18n="actions.upload">Subir</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="new-folder-btn">
|
||||
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i> <span data-i18n="actions.new_folder">Nueva carpeta</span>
|
||||
<i class="fas fa-folder-plus"></i>
|
||||
<span data-i18n="actions.new_folder">Nueva carpeta</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -164,5 +169,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Modal (for New Folder, Rename, etc.) -->
|
||||
<div id="input-modal" class="modal-overlay">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<div class="modal-icon">
|
||||
<i id="modal-icon" class="fas fa-folder-plus"></i>
|
||||
</div>
|
||||
<h3 id="modal-title" data-i18n="dialogs.new_folder_title">Nueva carpeta</h3>
|
||||
<button class="modal-close-btn" id="modal-close-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label id="modal-label" for="modal-input" data-i18n="dialogs.folder_name">Nombre de la carpeta</label>
|
||||
<input type="text" id="modal-input" class="modal-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="modal-cancel-btn" data-i18n="actions.cancel">Cancelar</button>
|
||||
<button class="btn btn-primary" id="modal-confirm-btn" data-i18n="actions.confirm">Crear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+100
-9
@@ -181,8 +181,8 @@ function setupEventListeners() {
|
||||
});
|
||||
|
||||
// New folder button
|
||||
elements.newFolderBtn.addEventListener('click', () => {
|
||||
const folderName = prompt(window.i18n ? window.i18n.t('dialogs.new_name') : 'Nombre de la carpeta:');
|
||||
elements.newFolderBtn.addEventListener('click', async () => {
|
||||
const folderName = await window.Modal.promptNewFolder();
|
||||
if (folderName) {
|
||||
fileOps.createFolder(folderName);
|
||||
}
|
||||
@@ -256,7 +256,7 @@ function setupEventListeners() {
|
||||
elements.actionsBar.innerHTML = `
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-danger" id="empty-trash-btn">
|
||||
<i class="fas fa-trash" style="margin-right: 5px;"></i>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<span>${window.i18n ? window.i18n.t('trash.empty_trash') : 'Vaciar papelera'}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -330,8 +330,8 @@ function setupEventListeners() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('new-folder-btn').addEventListener('click', () => {
|
||||
const folderName = prompt(window.i18n ? window.i18n.t('dialogs.new_name') : 'Nombre de la carpeta:');
|
||||
document.getElementById('new-folder-btn').addEventListener('click', async () => {
|
||||
const folderName = await window.Modal.promptNewFolder();
|
||||
if (folderName) {
|
||||
fileOps.createFolder(folderName);
|
||||
}
|
||||
@@ -899,8 +899,8 @@ function switchToFilesView() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('new-folder-btn').addEventListener('click', () => {
|
||||
const folderName = prompt(window.i18n ? window.i18n.t('dialogs.new_name') : 'Nombre de la carpeta:');
|
||||
document.getElementById('new-folder-btn').addEventListener('click', async () => {
|
||||
const folderName = await window.Modal.promptNewFolder();
|
||||
if (folderName) {
|
||||
fileOps.createFolder(folderName);
|
||||
}
|
||||
@@ -1136,6 +1136,59 @@ window.switchToSharedView = switchToSharedView;
|
||||
window.switchToFavoritesView = switchToFavoritesView;
|
||||
window.switchToRecentFilesView = switchToRecentFilesView;
|
||||
|
||||
/**
|
||||
* Fetch updated user data from the server (including storage usage)
|
||||
* This calls the /api/auth/me endpoint which also triggers storage recalculation
|
||||
*/
|
||||
async function refreshUserData() {
|
||||
const TOKEN_KEY = 'oxicloud_token';
|
||||
const USER_DATA_KEY = 'oxicloud_user';
|
||||
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
console.log('refreshUserData called, token:', token ? token.substring(0, 20) + '...' : 'null');
|
||||
|
||||
if (!token || token === 'mock_token_emergency_bypass' || token === 'emergency_token') {
|
||||
console.log('No valid token, skipping user data refresh');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Fetching /api/auth/me...');
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('/api/auth/me response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to fetch user data:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
console.log('Refreshed user data from server:', userData);
|
||||
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
|
||||
|
||||
// Update local storage with fresh data
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
|
||||
|
||||
// Update storage display with actual values
|
||||
updateStorageUsageDisplay(userData);
|
||||
|
||||
return userData;
|
||||
} catch (error) {
|
||||
console.error('Error refreshing user data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose refreshUserData globally
|
||||
window.refreshUserData = refreshUserData;
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and load user's home folder
|
||||
*/
|
||||
@@ -1192,6 +1245,22 @@ function checkAuthentication() {
|
||||
if (userAvatar) {
|
||||
userAvatar.textContent = userInitials;
|
||||
}
|
||||
|
||||
// Show cached storage first, then try to refresh from server
|
||||
updateStorageUsageDisplay(userData);
|
||||
|
||||
// Try to get updated storage from server (if we have a real token)
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token && token !== 'mock_token_emergency_bypass' && token !== 'emergency_token') {
|
||||
console.log('Bypass mode: Attempting to refresh storage from server...');
|
||||
refreshUserData().then(freshData => {
|
||||
if (freshData) {
|
||||
console.log('Bypass mode: Storage updated from server');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('Bypass mode: Could not refresh user data:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all counters to prevent loops
|
||||
@@ -1238,9 +1307,19 @@ function checkAuthentication() {
|
||||
userAvatar.textContent = userInitials;
|
||||
}
|
||||
|
||||
// Update storage usage information
|
||||
// Update storage usage information with cached data first (for fast display)
|
||||
updateStorageUsageDisplay(userData);
|
||||
|
||||
// Then refresh user data from server in the background to get updated storage
|
||||
// This triggers the backend to recalculate storage and returns fresh data
|
||||
refreshUserData().then(freshData => {
|
||||
if (freshData) {
|
||||
console.log('Storage usage updated from server');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('Could not refresh user data:', err);
|
||||
});
|
||||
|
||||
// Find and load the user's home folder
|
||||
findUserHomeFolder(userData.username);
|
||||
} else {
|
||||
@@ -1506,7 +1585,19 @@ function updateStorageUsageDisplay(userData) {
|
||||
}
|
||||
|
||||
if (storageInfo) {
|
||||
storageInfo.textContent = `${usagePercentage}% usado (${usedFormatted} / ${quotaFormatted})`;
|
||||
// Remove data-i18n attribute to prevent i18n from overwriting our value
|
||||
storageInfo.removeAttribute('data-i18n');
|
||||
|
||||
// Use i18n if available
|
||||
if (window.i18n && window.i18n.t) {
|
||||
storageInfo.textContent = window.i18n.t('storage.used', {
|
||||
percentage: usagePercentage,
|
||||
used: usedFormatted,
|
||||
total: quotaFormatted
|
||||
});
|
||||
} else {
|
||||
storageInfo.textContent = `${usagePercentage}% used (${usedFormatted} / ${quotaFormatted})`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
|
||||
|
||||
+220
-11
@@ -15,9 +15,189 @@ const TOKEN_KEY = 'oxicloud_token';
|
||||
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
|
||||
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
|
||||
const USER_DATA_KEY = 'oxicloud_user';
|
||||
const LOCALE_KEY = 'oxicloud-locale';
|
||||
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
|
||||
|
||||
// Language selector texts (used before i18n is loaded)
|
||||
const LANGUAGE_TEXTS = {
|
||||
en: {
|
||||
title: 'Welcome to OxiCloud',
|
||||
subtitle: 'Please select your language',
|
||||
continue: 'Continue'
|
||||
},
|
||||
es: {
|
||||
title: 'Bienvenido a OxiCloud',
|
||||
subtitle: 'Por favor, selecciona tu idioma',
|
||||
continue: 'Continuar'
|
||||
},
|
||||
zh: {
|
||||
title: '欢迎使用 OxiCloud',
|
||||
subtitle: '请选择您的语言',
|
||||
continue: '继续'
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this is a first run (no locale saved)
|
||||
function isFirstRun() {
|
||||
return !localStorage.getItem(LOCALE_KEY);
|
||||
}
|
||||
|
||||
// Check system status from the server
|
||||
async function checkSystemStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/status');
|
||||
if (!response.ok) {
|
||||
console.warn('Could not check system status, assuming initialized');
|
||||
return { initialized: true, admin_count: 1, registration_allowed: true };
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error checking system status:', error);
|
||||
return { initialized: true, admin_count: 1, registration_allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize language selector panel
|
||||
function initLanguageSelector() {
|
||||
const languagePanel = document.getElementById('language-panel');
|
||||
const languageOptions = document.querySelectorAll('.language-option');
|
||||
const continueBtn = document.getElementById('language-continue');
|
||||
let selectedLanguage = null;
|
||||
|
||||
if (!languagePanel) return;
|
||||
|
||||
// Handle language option clicks
|
||||
languageOptions.forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
// Remove selected class from all options
|
||||
languageOptions.forEach(opt => opt.classList.remove('selected'));
|
||||
// Add selected class to clicked option
|
||||
option.classList.add('selected');
|
||||
// Check the radio button
|
||||
option.querySelector('input[type="radio"]').checked = true;
|
||||
// Store selected language
|
||||
selectedLanguage = option.getAttribute('data-lang');
|
||||
// Enable continue button
|
||||
continueBtn.disabled = false;
|
||||
|
||||
// Update UI texts based on selected language
|
||||
updateLanguagePanelTexts(selectedLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle continue button click
|
||||
continueBtn.addEventListener('click', async () => {
|
||||
if (!selectedLanguage) return;
|
||||
|
||||
// Save locale preference
|
||||
localStorage.setItem(LOCALE_KEY, selectedLanguage);
|
||||
localStorage.setItem(FIRST_RUN_KEY, 'true');
|
||||
|
||||
// Update i18n if available
|
||||
if (window.i18n && window.i18n.setLocale) {
|
||||
await window.i18n.setLocale(selectedLanguage);
|
||||
}
|
||||
|
||||
// Hide language panel
|
||||
languagePanel.style.display = 'none';
|
||||
|
||||
// Check system status to determine which panel to show
|
||||
const systemStatus = await checkSystemStatus();
|
||||
console.log('System status after language selection:', systemStatus);
|
||||
|
||||
if (!systemStatus.initialized) {
|
||||
// No admin exists - show admin setup
|
||||
console.log('No admin exists, showing admin setup panel');
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('register-panel').style.display = 'none';
|
||||
document.getElementById('admin-setup-panel').style.display = 'block';
|
||||
|
||||
// Hide the "Already set up? Sign in" link
|
||||
const backToLoginLink = document.getElementById('back-to-login');
|
||||
if (backToLoginLink) {
|
||||
backToLoginLink.parentElement.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Admin exists - show login panel
|
||||
document.getElementById('login-panel').style.display = 'block';
|
||||
}
|
||||
|
||||
// Translate the page with new locale
|
||||
if (window.i18n && window.i18n.translatePage) {
|
||||
window.i18n.translatePage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update language panel texts based on selected language
|
||||
function updateLanguagePanelTexts(lang) {
|
||||
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
|
||||
const titleEl = document.getElementById('language-title');
|
||||
const subtitleEl = document.getElementById('language-subtitle');
|
||||
const continueBtn = document.getElementById('language-continue');
|
||||
|
||||
if (titleEl) titleEl.textContent = texts.title;
|
||||
if (subtitleEl) subtitleEl.textContent = texts.subtitle;
|
||||
if (continueBtn) continueBtn.textContent = texts.continue;
|
||||
}
|
||||
|
||||
// Show appropriate panel based on system status and first run
|
||||
async function showInitialPanel() {
|
||||
const languagePanel = document.getElementById('language-panel');
|
||||
const loginPanel = document.getElementById('login-panel');
|
||||
const adminSetupPanel = document.getElementById('admin-setup-panel');
|
||||
const registerPanel = document.getElementById('register-panel');
|
||||
|
||||
if (!languagePanel || !loginPanel) return;
|
||||
|
||||
// ALWAYS check if this is user's first run (language selection) FIRST
|
||||
// Language selection should happen before anything else
|
||||
if (isFirstRun()) {
|
||||
// First run - show language selector first
|
||||
// After language is selected, the continue button handler will check system status
|
||||
console.log('First run - showing language selector');
|
||||
languagePanel.style.display = 'block';
|
||||
loginPanel.style.display = 'none';
|
||||
registerPanel.style.display = 'none';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Language already selected - now check system status
|
||||
const systemStatus = await checkSystemStatus();
|
||||
console.log('System status:', systemStatus);
|
||||
|
||||
if (!systemStatus.initialized) {
|
||||
// No admin exists - this is a fresh install, show admin setup
|
||||
console.log('Fresh install detected - showing admin setup');
|
||||
languagePanel.style.display = 'none';
|
||||
loginPanel.style.display = 'none';
|
||||
registerPanel.style.display = 'none';
|
||||
adminSetupPanel.style.display = 'block';
|
||||
|
||||
// Hide the "Already set up? Sign in" link since there's no admin yet
|
||||
const backToLoginLink = document.getElementById('back-to-login');
|
||||
if (backToLoginLink) {
|
||||
backToLoginLink.parentElement.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// System is initialized - show login panel
|
||||
languagePanel.style.display = 'none';
|
||||
loginPanel.style.display = 'block';
|
||||
registerPanel.style.display = 'none';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
|
||||
// Hide the admin setup link if admin already exists
|
||||
const showAdminSetupLink = document.getElementById('show-admin-setup');
|
||||
if (showAdminSetupLink && systemStatus.admin_count > 0) {
|
||||
showAdminSetupLink.parentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// DOM elements
|
||||
let loginPanel, registerPanel, adminSetupPanel;
|
||||
let loginPanel, registerPanel, adminSetupPanel, languagePanel;
|
||||
let loginForm, registerForm, adminSetupForm;
|
||||
let loginError, registerError, registerSuccess, adminSetupError;
|
||||
|
||||
@@ -29,6 +209,7 @@ function initLoginElements() {
|
||||
return false;
|
||||
}
|
||||
|
||||
languagePanel = document.getElementById('language-panel');
|
||||
loginPanel = document.getElementById('login-panel');
|
||||
registerPanel = document.getElementById('register-panel');
|
||||
adminSetupPanel = document.getElementById('admin-setup-panel');
|
||||
@@ -41,6 +222,9 @@ function initLoginElements() {
|
||||
registerError = document.getElementById('register-error');
|
||||
registerSuccess = document.getElementById('register-success');
|
||||
adminSetupError = document.getElementById('admin-setup-error');
|
||||
|
||||
// Initialize language selector
|
||||
initLanguageSelector();
|
||||
|
||||
// Panel toggles
|
||||
document.getElementById('show-register').addEventListener('click', () => {
|
||||
@@ -138,6 +322,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
authInitialized = true;
|
||||
|
||||
// Show appropriate panel (language selector on first run, login otherwise, or admin setup if no admin)
|
||||
// This is async so we call it and let it run
|
||||
showInitialPanel().then(() => {
|
||||
console.log('Initial panel shown based on system status');
|
||||
}).catch(err => {
|
||||
console.error('Error showing initial panel:', err);
|
||||
});
|
||||
|
||||
// Siempre limpiar los contadores al cargar la página de login
|
||||
// para asegurar que no quedamos atrapados en un bucle
|
||||
console.log('Login page loaded, clearing all counters');
|
||||
@@ -308,7 +500,8 @@ if (isLoginPage && registerForm) {
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
registerError.textContent = 'Las contraseñas no coinciden';
|
||||
const errorMsg = window.i18n ? window.i18n.t('auth.passwords_mismatch') : 'Las contraseñas no coinciden';
|
||||
registerError.textContent = errorMsg;
|
||||
registerError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
@@ -317,7 +510,8 @@ if (isLoginPage && registerForm) {
|
||||
const data = await register(username, email, password);
|
||||
|
||||
// Show success message
|
||||
registerSuccess.textContent = '¡Cuenta creada con éxito! Puedes iniciar sesión ahora.';
|
||||
const successMsg = window.i18n ? window.i18n.t('auth.account_success') : '¡Cuenta creada con éxito! Puedes iniciar sesión ahora.';
|
||||
registerSuccess.textContent = successMsg;
|
||||
registerSuccess.style.display = 'block';
|
||||
|
||||
// Clear form
|
||||
@@ -329,7 +523,8 @@ if (isLoginPage && registerForm) {
|
||||
registerPanel.style.display = 'none';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
registerError.textContent = error.message || 'Error al registrar cuenta';
|
||||
const errorMsg = window.i18n ? window.i18n.t('auth.admin_create_error') : 'Error al registrar cuenta';
|
||||
registerError.textContent = error.message || errorMsg;
|
||||
registerError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
@@ -340,8 +535,10 @@ if (isLoginPage && adminSetupForm) {
|
||||
adminSetupForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous errors
|
||||
// Clear previous errors/success messages
|
||||
adminSetupError.style.display = 'none';
|
||||
const adminSetupSuccess = document.getElementById('admin-setup-success');
|
||||
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
|
||||
|
||||
const email = document.getElementById('admin-email').value;
|
||||
const password = document.getElementById('admin-password').value;
|
||||
@@ -349,7 +546,8 @@ if (isLoginPage && adminSetupForm) {
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
adminSetupError.textContent = 'Las contraseñas no coinciden';
|
||||
const errorMsg = window.i18n ? window.i18n.t('auth.passwords_mismatch') : 'Las contraseñas no coinciden';
|
||||
adminSetupError.textContent = errorMsg;
|
||||
adminSetupError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
@@ -358,13 +556,24 @@ if (isLoginPage && adminSetupForm) {
|
||||
// Register admin account
|
||||
const data = await register('admin', email, password, 'admin');
|
||||
|
||||
// Show success and switch to login
|
||||
alert('¡Cuenta de administrador creada con éxito! Ahora puedes iniciar sesión.');
|
||||
// Show success message in the GUI instead of alert
|
||||
const successMsg = window.i18n ? window.i18n.t('auth.admin_success') : '¡Cuenta de administrador creada con éxito! Ahora puedes iniciar sesión.';
|
||||
|
||||
if (adminSetupSuccess) {
|
||||
adminSetupSuccess.textContent = successMsg;
|
||||
adminSetupSuccess.style.display = 'block';
|
||||
}
|
||||
|
||||
// Wait 2 seconds then switch to login panel
|
||||
setTimeout(() => {
|
||||
loginPanel.style.display = 'block';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
|
||||
}, 2000);
|
||||
|
||||
loginPanel.style.display = 'block';
|
||||
adminSetupPanel.style.display = 'none';
|
||||
} catch (error) {
|
||||
adminSetupError.textContent = error.message || 'Error al crear cuenta de administrador';
|
||||
const errorMsg = window.i18n ? window.i18n.t('auth.admin_create_error') : 'Error al crear cuenta de administrador';
|
||||
adminSetupError.textContent = error.message || errorMsg;
|
||||
adminSetupError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
+25
-53
@@ -21,20 +21,26 @@ const fileOps = {
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const file = files[i];
|
||||
const formData = new FormData();
|
||||
|
||||
// IMPORTANT: folder_id MUST be added BEFORE file for multipart processing
|
||||
// The backend reads fields in order, and needs folder_id before processing the file
|
||||
const targetFolderId = window.app.currentPath || window.app.userHomeFolderId;
|
||||
|
||||
if (targetFolderId) {
|
||||
formData.append('folder_id', targetFolderId);
|
||||
}
|
||||
|
||||
// Add the file AFTER folder_id
|
||||
formData.append('file', file);
|
||||
|
||||
if (window.app.currentPath) {
|
||||
formData.append('folder_id', window.app.currentPath);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Uploading file to current path: ${window.app.currentPath || 'root'}`);
|
||||
console.log(`Uploading file to folder: ${targetFolderId || 'root'}`);
|
||||
|
||||
// Usamos la URL correcta para la subida de archivos
|
||||
console.log('Formulario a enviar:', {
|
||||
file: file.name,
|
||||
size: file.size,
|
||||
folder_id: window.app.currentPath || 'root'
|
||||
folder_id: targetFolderId || 'root'
|
||||
});
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
@@ -62,63 +68,29 @@ const fileOps = {
|
||||
const responseData = await response.json();
|
||||
console.log(`Successfully uploaded ${file.name}`, responseData);
|
||||
|
||||
// Agregar el archivo a la vista inmediatamente para mostrar retroalimentación instantánea
|
||||
// Esto permite que el usuario vea el archivo aunque el refresco posterior falle
|
||||
if (window.ui && window.ui.addFileToView) {
|
||||
console.log('Añadiendo archivo subido directamente a la vista:', responseData);
|
||||
window.ui.addFileToView(responseData);
|
||||
window.ui.updateFileIcons();
|
||||
}
|
||||
// Show success notification immediately
|
||||
window.ui.showNotification('Archivo subido', `${file.name} completado`);
|
||||
|
||||
if (i === totalFiles - 1) {
|
||||
// Last file uploaded
|
||||
console.log('Recargando lista de archivos después de subida');
|
||||
// Last file uploaded - wait and reload once
|
||||
console.log('Último archivo subido, esperando antes de recargar...');
|
||||
|
||||
// Wait for backend to persist
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// Single reload with force refresh
|
||||
try {
|
||||
// Esperar 1500ms para asegurar que los mapeos de ID se guarden
|
||||
// Tiempo aumentado significativamente para permitir al backend completar persistencia
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Forzar recarga de archivos con parámetro de bypass de caché
|
||||
const timestamp = new Date().getTime();
|
||||
const filesUrl = `/api/files?t=${timestamp}&folder_id=${window.app.currentPath || ''}`;
|
||||
|
||||
console.log(`Recargando lista de archivos desde: ${filesUrl}`);
|
||||
const filesResponse = await fetch(filesUrl, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (filesResponse.ok) {
|
||||
const files = await filesResponse.json();
|
||||
console.log(`Recarga de archivos completada, obtenidos ${files.length} archivos`);
|
||||
}
|
||||
|
||||
// Esperar un momento más antes de la recarga final
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Hacer una única recarga final forzando refresco completo
|
||||
await window.loadFiles({forceRefresh: true});
|
||||
|
||||
// Asegurarse de que no haya recargas adicionales o duplicados
|
||||
} catch (reloadError) {
|
||||
console.error("Error durante recarga de archivos:", reloadError);
|
||||
// Intentar recargar lista de nuevo en caso de error
|
||||
await window.loadFiles({forceRefresh: true});
|
||||
// Segundo intento con retraso y forzando refresco
|
||||
setTimeout(() => window.loadFiles({forceRefresh: true}), 500);
|
||||
console.error("Error recargando archivos:", reloadError);
|
||||
}
|
||||
|
||||
// Hide upload UI
|
||||
setTimeout(() => {
|
||||
document.getElementById('dropzone').style.display = 'none';
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
if (dropzone) dropzone.style.display = 'none';
|
||||
uploadProgressDiv.style.display = 'none';
|
||||
}, 1000);
|
||||
|
||||
// Show success notification
|
||||
window.ui.showNotification('Archivo subido', `${file.name} completado`);
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.text();
|
||||
|
||||
+141
-28
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Language Selector Component for OxiCloud
|
||||
* Custom styled dropdown with flags
|
||||
*/
|
||||
|
||||
// Language codes and names
|
||||
// Language codes, names, and flag emojis
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Español' },
|
||||
{ code: 'zh', name: '中文' }
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates and initializes a language selector component
|
||||
* Creates and initializes a custom language selector component
|
||||
* @param {string} containerId - ID of the container element
|
||||
*/
|
||||
function createLanguageSelector(containerId = 'language-selector') {
|
||||
@@ -23,45 +24,157 @@ function createLanguageSelector(containerId = 'language-selector') {
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Create dropdown
|
||||
const select = document.createElement('select');
|
||||
select.className = 'language-select';
|
||||
select.setAttribute('aria-label', 'Select language');
|
||||
// Ensure container has the right class
|
||||
container.className = 'language-selector';
|
||||
|
||||
// Add options
|
||||
// Get current language
|
||||
const currentLocale = window.i18n ? window.i18n.getCurrentLocale() : 'en';
|
||||
const currentLang = languages.find(l => l.code === currentLocale) || languages[0];
|
||||
|
||||
// Create toggle button
|
||||
const toggle = document.createElement('div');
|
||||
toggle.className = 'language-selector-toggle';
|
||||
toggle.setAttribute('role', 'button');
|
||||
toggle.setAttribute('aria-haspopup', 'listbox');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
toggle.setAttribute('tabindex', '0');
|
||||
toggle.innerHTML = `
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="lang-code">${currentLang.code.toUpperCase()}</span>
|
||||
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||
`;
|
||||
|
||||
// Create dropdown menu
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'language-selector-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
// Add language options
|
||||
languages.forEach(lang => {
|
||||
const option = document.createElement('option');
|
||||
option.value = lang.code;
|
||||
option.textContent = lang.name;
|
||||
select.appendChild(option);
|
||||
const option = document.createElement('div');
|
||||
option.className = `language-option${lang.code === currentLocale ? ' active' : ''}`;
|
||||
option.setAttribute('role', 'option');
|
||||
option.setAttribute('data-lang', lang.code);
|
||||
option.setAttribute('aria-selected', lang.code === currentLocale);
|
||||
option.innerHTML = `
|
||||
<span class="lang-flag">${lang.flag}</span>
|
||||
<span class="lang-name">${lang.name}</span>
|
||||
<i class="fas fa-check lang-check"></i>
|
||||
`;
|
||||
|
||||
option.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await selectLanguage(lang.code, container);
|
||||
});
|
||||
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
|
||||
// Set current language
|
||||
const currentLocale = window.i18n ? window.i18n.getCurrentLocale() : 'en';
|
||||
select.value = currentLocale;
|
||||
// Clear and build container
|
||||
container.innerHTML = '';
|
||||
container.appendChild(toggle);
|
||||
container.appendChild(dropdown);
|
||||
|
||||
// Add change event
|
||||
select.addEventListener('change', async (e) => {
|
||||
const locale = e.target.value;
|
||||
if (window.i18n) {
|
||||
await window.i18n.setLocale(locale);
|
||||
// Toggle dropdown on click
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown(container);
|
||||
});
|
||||
|
||||
// Keyboard support
|
||||
toggle.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleDropdown(container);
|
||||
} else if (e.key === 'Escape') {
|
||||
closeDropdown(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Add to container
|
||||
container.innerHTML = '';
|
||||
container.appendChild(select);
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!container.contains(e.target)) {
|
||||
closeDropdown(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listener for locale changes
|
||||
// Listen for locale changes from i18n system
|
||||
window.addEventListener('localeChanged', (e) => {
|
||||
select.value = e.detail.locale;
|
||||
updateSelectedLanguage(e.detail.locale, container);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dropdown open/closed
|
||||
*/
|
||||
function toggleDropdown(container) {
|
||||
const isOpen = container.classList.contains('open');
|
||||
if (isOpen) {
|
||||
closeDropdown(container);
|
||||
} else {
|
||||
openDropdown(container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown
|
||||
*/
|
||||
function openDropdown(container) {
|
||||
container.classList.add('open');
|
||||
const toggle = container.querySelector('.language-selector-toggle');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown
|
||||
*/
|
||||
function closeDropdown(container) {
|
||||
container.classList.remove('open');
|
||||
const toggle = container.querySelector('.language-selector-toggle');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a language
|
||||
*/
|
||||
async function selectLanguage(langCode, container) {
|
||||
// Update i18n if available
|
||||
if (window.i18n) {
|
||||
await window.i18n.setLocale(langCode);
|
||||
}
|
||||
|
||||
updateSelectedLanguage(langCode, container);
|
||||
closeDropdown(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the UI to reflect selected language
|
||||
*/
|
||||
function updateSelectedLanguage(langCode, container) {
|
||||
const lang = languages.find(l => l.code === langCode) || languages[0];
|
||||
|
||||
// Update toggle button text
|
||||
const langCodeSpan = container.querySelector('.lang-code');
|
||||
if (langCodeSpan) {
|
||||
langCodeSpan.textContent = lang.code.toUpperCase();
|
||||
}
|
||||
|
||||
// Update active state on options
|
||||
const options = container.querySelectorAll('.language-option');
|
||||
options.forEach(option => {
|
||||
const isActive = option.getAttribute('data-lang') === langCode;
|
||||
option.classList.toggle('active', isActive);
|
||||
option.setAttribute('aria-selected', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
// Create language selector when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Create language selector
|
||||
createLanguageSelector();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Modal System for OxiCloud
|
||||
* Provides modern, styled modals to replace browser prompts/alerts
|
||||
*/
|
||||
|
||||
const Modal = {
|
||||
// Modal element references
|
||||
overlay: null,
|
||||
container: null,
|
||||
icon: null,
|
||||
title: null,
|
||||
label: null,
|
||||
input: null,
|
||||
cancelBtn: null,
|
||||
confirmBtn: null,
|
||||
closeBtn: null,
|
||||
|
||||
// Current callback
|
||||
onConfirm: null,
|
||||
onCancel: null,
|
||||
|
||||
/**
|
||||
* Initialize modal system
|
||||
*/
|
||||
init() {
|
||||
this.overlay = document.getElementById('input-modal');
|
||||
if (!this.overlay) {
|
||||
console.warn('Modal overlay not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.icon = document.getElementById('modal-icon');
|
||||
this.title = document.getElementById('modal-title');
|
||||
this.label = document.getElementById('modal-label');
|
||||
this.input = document.getElementById('modal-input');
|
||||
this.cancelBtn = document.getElementById('modal-cancel-btn');
|
||||
this.confirmBtn = document.getElementById('modal-confirm-btn');
|
||||
this.closeBtn = document.getElementById('modal-close-btn');
|
||||
|
||||
// Event listeners
|
||||
this.cancelBtn.addEventListener('click', () => this.close(false));
|
||||
this.closeBtn.addEventListener('click', () => this.close(false));
|
||||
this.confirmBtn.addEventListener('click', () => this.confirm());
|
||||
|
||||
// Close on overlay click
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) {
|
||||
this.close(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Enter and Escape keys
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.confirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.close(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show input modal (replacement for prompt())
|
||||
* @param {Object} options - Modal configuration
|
||||
* @param {string} options.title - Modal title
|
||||
* @param {string} options.label - Input label
|
||||
* @param {string} options.placeholder - Input placeholder
|
||||
* @param {string} options.value - Initial input value
|
||||
* @param {string} options.icon - Font Awesome icon class (e.g., 'fa-folder-plus')
|
||||
* @param {string} options.confirmText - Confirm button text
|
||||
* @param {string} options.cancelText - Cancel button text
|
||||
* @returns {Promise<string|null>} - Resolves with input value or null if cancelled
|
||||
*/
|
||||
prompt(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const {
|
||||
title = 'Input',
|
||||
label = '',
|
||||
placeholder = '',
|
||||
value = '',
|
||||
icon = 'fa-keyboard',
|
||||
confirmText = null,
|
||||
cancelText = null
|
||||
} = options;
|
||||
|
||||
// Set modal content
|
||||
this.icon.className = `fas ${icon}`;
|
||||
this.title.textContent = title;
|
||||
this.label.textContent = label;
|
||||
this.input.placeholder = placeholder;
|
||||
this.input.value = value;
|
||||
|
||||
// Set button text (use i18n if available)
|
||||
if (confirmText) {
|
||||
this.confirmBtn.textContent = confirmText;
|
||||
} else if (window.i18n) {
|
||||
this.confirmBtn.textContent = window.i18n.t('actions.confirm');
|
||||
}
|
||||
|
||||
if (cancelText) {
|
||||
this.cancelBtn.textContent = cancelText;
|
||||
} else if (window.i18n) {
|
||||
this.cancelBtn.textContent = window.i18n.t('actions.cancel');
|
||||
}
|
||||
|
||||
// Set callbacks
|
||||
this.onConfirm = () => {
|
||||
const inputValue = this.input.value.trim();
|
||||
resolve(inputValue || null);
|
||||
};
|
||||
this.onCancel = () => resolve(null);
|
||||
|
||||
// Show modal
|
||||
this.open();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show modal for creating new folder
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
promptNewFolder() {
|
||||
const t = window.i18n ? window.i18n.t.bind(window.i18n) : (k) => k;
|
||||
|
||||
return this.prompt({
|
||||
title: t('dialogs.new_folder_title') || 'Nueva carpeta',
|
||||
label: t('dialogs.folder_name') || 'Nombre de la carpeta',
|
||||
placeholder: t('dialogs.folder_placeholder') || 'Mi carpeta',
|
||||
icon: 'fa-folder-plus',
|
||||
confirmText: t('actions.create') || 'Crear'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show modal for renaming
|
||||
* @param {string} currentName - Current name of file/folder
|
||||
* @param {boolean} isFolder - Whether it's a folder
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
promptRename(currentName, isFolder = false) {
|
||||
const t = window.i18n ? window.i18n.t.bind(window.i18n) : (k) => k;
|
||||
|
||||
return this.prompt({
|
||||
title: t('dialogs.rename_title') || 'Renombrar',
|
||||
label: t('dialogs.new_name') || 'Nuevo nombre',
|
||||
placeholder: '',
|
||||
value: currentName,
|
||||
icon: isFolder ? 'fa-folder' : 'fa-file',
|
||||
confirmText: t('actions.rename') || 'Renombrar'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the modal
|
||||
*/
|
||||
open() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
// Show overlay
|
||||
this.overlay.style.display = 'flex';
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
this.overlay.classList.add('active');
|
||||
});
|
||||
|
||||
// Focus input after animation
|
||||
setTimeout(() => {
|
||||
this.input.focus();
|
||||
this.input.select();
|
||||
}, 100);
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
* @param {boolean} confirmed - Whether the action was confirmed
|
||||
*/
|
||||
close(confirmed = false) {
|
||||
if (!this.overlay) return;
|
||||
|
||||
this.overlay.classList.remove('active');
|
||||
|
||||
setTimeout(() => {
|
||||
this.overlay.style.display = 'none';
|
||||
|
||||
if (!confirmed && this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
// Clear callbacks
|
||||
this.onConfirm = null;
|
||||
this.onCancel = null;
|
||||
}, 200);
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm the action
|
||||
*/
|
||||
confirm() {
|
||||
if (this.onConfirm) {
|
||||
this.onConfirm();
|
||||
}
|
||||
this.close(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Modal.init();
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
window.Modal = Modal;
|
||||
+41
-2
@@ -26,7 +26,10 @@
|
||||
"copy": "Copy",
|
||||
"notify": "Notify",
|
||||
"send": "Send",
|
||||
"clear_recent": "Clear recent"
|
||||
"clear_recent": "Clear recent",
|
||||
"logout": "Log out",
|
||||
"create": "Create",
|
||||
"search_btn": "Search"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Share Link",
|
||||
@@ -168,6 +171,10 @@
|
||||
"dialogs": {
|
||||
"rename_folder": "Rename folder",
|
||||
"new_name": "New name",
|
||||
"new_folder_title": "New folder",
|
||||
"folder_name": "Folder name",
|
||||
"folder_placeholder": "My folder",
|
||||
"rename_title": "Rename",
|
||||
"move_file": "Move file",
|
||||
"select_destination": "Select destination folder",
|
||||
"root": "Root",
|
||||
@@ -245,7 +252,16 @@
|
||||
"admin_email": "Admin email",
|
||||
"admin_password": "Admin password",
|
||||
"create_admin": "Create administrator",
|
||||
"back_to_login": "Already set up?"
|
||||
"back_to_login": "Already set up?",
|
||||
"admin_success": "Administrator account created successfully! You can now sign in.",
|
||||
"account_success": "Account created successfully! You can now sign in.",
|
||||
"passwords_mismatch": "Passwords do not match",
|
||||
"admin_create_error": "Error creating administrator account"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"calculating": "Calculating...",
|
||||
"used": "{{percentage}}% used ({{used}} / {{total}})"
|
||||
},
|
||||
"viewer": {
|
||||
"unsupported_file": "This file type cannot be previewed.",
|
||||
@@ -253,5 +269,28 @@
|
||||
"zoom_in": "Zoom in",
|
||||
"zoom_out": "Zoom out",
|
||||
"zoom_reset": "Reset zoom"
|
||||
},
|
||||
"language_selector": {
|
||||
"title": "Welcome to OxiCloud",
|
||||
"subtitle": "Please select your language",
|
||||
"continue": "Continue",
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"es": "Español",
|
||||
"zh": "中文"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"empty_state": "No favorites yet",
|
||||
"empty_hint": "Star files or folders to add them to your favorites",
|
||||
"add": "Add to favorites",
|
||||
"remove": "Remove from favorites"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Recent",
|
||||
"clear": "Clear recent",
|
||||
"accessed": "Accessed",
|
||||
"empty_state": "No recent files",
|
||||
"empty_hint": "Files you open will appear here"
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -145,7 +145,10 @@
|
||||
"copy": "Copiar",
|
||||
"notify": "Notificar",
|
||||
"send": "Enviar",
|
||||
"clear_recent": "Limpiar recientes"
|
||||
"clear_recent": "Limpiar recientes",
|
||||
"logout": "Cerrar sesión",
|
||||
"create": "Crear",
|
||||
"search_btn": "Buscar"
|
||||
},
|
||||
"files": {
|
||||
"name": "Nombre",
|
||||
@@ -168,6 +171,10 @@
|
||||
"dialogs": {
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
"new_name": "Nuevo nombre",
|
||||
"new_folder_title": "Nueva carpeta",
|
||||
"folder_name": "Nombre de la carpeta",
|
||||
"folder_placeholder": "Mi carpeta",
|
||||
"rename_title": "Renombrar",
|
||||
"move_file": "Mover archivo",
|
||||
"select_destination": "Selecciona la carpeta destino",
|
||||
"root": "Raíz",
|
||||
@@ -245,7 +252,16 @@
|
||||
"admin_email": "Email administrador",
|
||||
"admin_password": "Contraseña administrador",
|
||||
"create_admin": "Crear administrador",
|
||||
"back_to_login": "¿Ya está configurado?"
|
||||
"back_to_login": "¿Ya está configurado?",
|
||||
"admin_success": "¡Cuenta de administrador creada con éxito! Ahora puedes iniciar sesión.",
|
||||
"account_success": "¡Cuenta creada con éxito! Ahora puedes iniciar sesión.",
|
||||
"passwords_mismatch": "Las contraseñas no coinciden",
|
||||
"admin_create_error": "Error al crear cuenta de administrador"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Almacenamiento",
|
||||
"calculating": "Calculando...",
|
||||
"used": "{{percentage}}% usado ({{used}} / {{total}})"
|
||||
},
|
||||
"viewer": {
|
||||
"unsupported_file": "Este tipo de archivo no se puede previsualizar.",
|
||||
@@ -253,5 +269,28 @@
|
||||
"zoom_in": "Acercar",
|
||||
"zoom_out": "Alejar",
|
||||
"zoom_reset": "Restablecer zoom"
|
||||
},
|
||||
"language_selector": {
|
||||
"title": "Bienvenido a OxiCloud",
|
||||
"subtitle": "Por favor, selecciona tu idioma",
|
||||
"continue": "Continuar",
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"es": "Español",
|
||||
"zh": "中文"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"empty_state": "Aún no hay favoritos",
|
||||
"empty_hint": "Marca archivos o carpetas con estrella para añadirlos a favoritos",
|
||||
"add": "Añadir a favoritos",
|
||||
"remove": "Quitar de favoritos"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Recientes",
|
||||
"clear": "Limpiar recientes",
|
||||
"accessed": "Accedido",
|
||||
"empty_state": "No hay archivos recientes",
|
||||
"empty_hint": "Los archivos que abras aparecerán aquí"
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -26,7 +26,10 @@
|
||||
"copy": "复制",
|
||||
"notify": "通知",
|
||||
"send": "发送",
|
||||
"clear_recent": "清除最近"
|
||||
"clear_recent": "清除最近",
|
||||
"logout": "退出登录",
|
||||
"create": "创建",
|
||||
"search_btn": "搜索"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "共享链接",
|
||||
@@ -130,6 +133,10 @@
|
||||
"dialogs": {
|
||||
"rename_folder": "重命名文件夹",
|
||||
"new_name": "新名称",
|
||||
"new_folder_title": "新建文件夹",
|
||||
"folder_name": "文件夹名称",
|
||||
"folder_placeholder": "我的文件夹",
|
||||
"rename_title": "重命名",
|
||||
"move_file": "移动文件",
|
||||
"select_destination": "选择目标文件夹",
|
||||
"root": "根目录",
|
||||
@@ -207,7 +214,16 @@
|
||||
"admin_email": "管理员电子邮件",
|
||||
"admin_password": "管理员密码",
|
||||
"create_admin": "创建管理员",
|
||||
"back_to_login": "已设置完成?"
|
||||
"back_to_login": "已设置完成?",
|
||||
"admin_success": "管理员账号创建成功!您现在可以登录。",
|
||||
"account_success": "账号创建成功!您现在可以登录。",
|
||||
"passwords_mismatch": "密码不匹配",
|
||||
"admin_create_error": "创建管理员账号时出错"
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储空间",
|
||||
"calculating": "计算中...",
|
||||
"used": "{{percentage}}% 已使用 ({{used}} / {{total}})"
|
||||
},
|
||||
"viewer": {
|
||||
"unsupported_file": "无法预览此文件类型。",
|
||||
@@ -215,5 +231,28 @@
|
||||
"zoom_in": "放大",
|
||||
"zoom_out": "缩小",
|
||||
"zoom_reset": "重置缩放"
|
||||
},
|
||||
"language_selector": {
|
||||
"title": "欢迎使用 OxiCloud",
|
||||
"subtitle": "请选择您的语言",
|
||||
"continue": "继续",
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"es": "Español",
|
||||
"zh": "中文"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"empty_state": "还没有收藏",
|
||||
"empty_hint": "为文件或文件夹添加星标以将其添加到收藏夹",
|
||||
"add": "添加到收藏夹",
|
||||
"remove": "从收藏夹移除"
|
||||
},
|
||||
"recent": {
|
||||
"title": "最近",
|
||||
"clear": "清除最近",
|
||||
"accessed": "访问于",
|
||||
"empty_state": "没有最近文件",
|
||||
"empty_hint": "您打开的文件将显示在这里"
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -17,7 +17,47 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel" id="login-panel">
|
||||
<!-- Language Selector Panel - Shown first on fresh install -->
|
||||
<div class="auth-panel language-selector-panel" id="language-panel" style="display: none;">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title" id="language-title">Welcome to OxiCloud</h2>
|
||||
<p class="language-subtitle" id="language-subtitle">Please select your language</p>
|
||||
|
||||
<div class="language-options" id="language-options">
|
||||
<label class="language-option" data-lang="en">
|
||||
<input type="radio" name="language" value="en">
|
||||
<span class="language-radio"></span>
|
||||
<span class="language-flag">🇬🇧</span>
|
||||
<span class="language-name">English</span>
|
||||
</label>
|
||||
|
||||
<label class="language-option" data-lang="es">
|
||||
<input type="radio" name="language" value="es">
|
||||
<span class="language-radio"></span>
|
||||
<span class="language-flag">🇪🇸</span>
|
||||
<span class="language-name">Español</span>
|
||||
</label>
|
||||
|
||||
<label class="language-option" data-lang="zh">
|
||||
<input type="radio" name="language" value="zh">
|
||||
<span class="language-radio"></span>
|
||||
<span class="language-flag">🇨🇳</span>
|
||||
<span class="language-name">中文</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="auth-button" id="language-continue" disabled>Continue</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-panel" id="login-panel" style="display: none;">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
@@ -174,6 +214,7 @@
|
||||
</div>
|
||||
|
||||
<div class="auth-error" id="admin-setup-error"></div>
|
||||
<div class="auth-success" id="admin-setup-success"></div>
|
||||
|
||||
<form class="auth-form" id="admin-setup-form">
|
||||
<div class="auth-input-group">
|
||||
|
||||
+8
-11
@@ -44,11 +44,11 @@
|
||||
</div>
|
||||
|
||||
<div class="storage-container">
|
||||
<div class="storage-title">Almacenamiento</div>
|
||||
<div class="storage-title" data-i18n="storage.title">Storage</div>
|
||||
<div class="storage-bar">
|
||||
<div class="storage-fill"></div>
|
||||
</div>
|
||||
<div class="storage-info">56% usado (5.6GB / 10GB)</div>
|
||||
<div class="storage-info" data-i18n="storage.calculating">Calculating...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,18 +59,15 @@
|
||||
<div class="search-container">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" id="shared-search" data-i18n-placeholder="actions.search" placeholder="Search shared items...">
|
||||
<button id="shared-search-btn" class="search-button"><i class="fas fa-search"></i></button>
|
||||
<button id="shared-search-btn" class="search-button" data-i18n-title="actions.search_btn" title="Search">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-controls">
|
||||
<div id="language-selector" class="language-selector">
|
||||
<select id="language-selector-dropdown">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="user-avatar">MR</div>
|
||||
<div id="logout-btn" class="logout-btn" title="Cerrar sesión">
|
||||
<div id="language-selector"></div>
|
||||
<div class="user-avatar" id="user-avatar">AD</div>
|
||||
<div id="logout-btn" class="logout-btn" data-i18n-title="actions.logout" title="Log out">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user