From 834c404cf3544029b445f4b407306f46874cb079 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 24 Jul 2026 17:32:50 +0800 Subject: [PATCH] refactor(webdav): migrate protocol logic to aster_forge_webdav crate - Remove local implementations of `DavPath`, `DavFileSystem`, `DavLockSystem`, and related traits from `src/webdav/dav.rs`; replace all imports with `aster_forge_webdav::dav` - Delete in-tree WebDAV protocol modules (`props`, `protocol`, `responses`, `resources`, `locks`, `transfer`, `deltav`) and delegate the full protocol exchange to `aster_forge_webdav::handler::WebDavHandler` - Add `aster_forge_webdav` dependency to `Cargo.toml` - Replace local `is_system_namespace`, `is_dav_namespace`, and `is_protected_namespace` helpers in `property.rs` with imports from `aster_forge_webdav` - Replace local `normalize_path`, `path_ancestors`, `lock_paths_overlap`, `lock_path_is_under`, and `unlock_request_targets_lock_scope` helpers in `db_lock_system.rs` with imports from `aster_forge_webdav::lock_paths` - Replace local `parse_basic_authorization` / base64 decoding in `auth.rs` with `aster_forge_webdav::auth::parse_basic_authorization` - Introduce `DavPropertyTarget` and `DavResourceKind` from Forge in `metadata.rs`, replacing the local `(EntityType, i64)` tuple - Implement `DavNamePolicy` for `SystemFileBlockPolicy` to satisfy the Forge handler interface - Add `AsterDavVersionProvider` in `version_provider.rs` as a persistence adapter implementing `aster_forge_webdav::deltav::DavVersionProvider` - Implement `open_download_stream` and `copy_dir_shallow` on `AsterDavFs` to satisfy the updated `DavFileSystem` contract from Forge - Update all test imports (`tests/webdav/`) to reference types from `aster_forge_webdav` directly --- Cargo.lock | 154 ++-- Cargo.toml | 1 + src/services/content/property.rs | 14 +- src/webdav/auth.rs | 47 +- src/webdav/dav.rs | 439 ---------- src/webdav/db_lock_system.rs | 81 +- src/webdav/deltav.rs | 238 ------ src/webdav/dir_entry.rs | 2 +- src/webdav/file/mod.rs | 2 +- src/webdav/file/tests.rs | 2 +- src/webdav/fs/mod.rs | 82 +- src/webdav/handler_tests/mod.rs | 23 +- src/webdav/locks/mod.rs | 485 ----------- src/webdav/metadata.rs | 13 +- src/webdav/mod.rs | 457 +--------- src/webdav/path_resolver.rs | 2 +- src/webdav/props/mod.rs | 1377 ------------------------------ src/webdav/protocol.rs | 1115 ------------------------ src/webdav/resources/mod.rs | 1052 ----------------------- src/webdav/responses.rs | 545 ------------ src/webdav/system_file.rs | 6 + src/webdav/transfer/mod.rs | 352 -------- src/webdav/version_provider.rs | 123 +++ tests/webdav/file.rs | 6 +- tests/webdav/lock_system.rs | 12 +- tests/webdav/path_resolver.rs | 2 +- 26 files changed, 363 insertions(+), 6269 deletions(-) delete mode 100644 src/webdav/dav.rs delete mode 100644 src/webdav/deltav.rs delete mode 100644 src/webdav/locks/mod.rs delete mode 100644 src/webdav/props/mod.rs delete mode 100644 src/webdav/protocol.rs delete mode 100644 src/webdav/resources/mod.rs delete mode 100644 src/webdav/responses.rs delete mode 100644 src/webdav/transfer/mod.rs create mode 100644 src/webdav/version_provider.rs diff --git a/Cargo.lock b/Cargo.lock index 606f4c499..6976c4add 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "aster_forge_tasks", "aster_forge_utils", "aster_forge_validation", + "aster_forge_webdav", "async-stream", "async-trait", "aws-credential-types", @@ -861,7 +862,7 @@ dependencies = [ [[package]] name = "aster_forge_actix_middleware" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "actix-governor", "actix-web", @@ -881,7 +882,7 @@ dependencies = [ [[package]] name = "aster_forge_actix_observability" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "actix-web", "aster_forge_metrics", @@ -891,7 +892,7 @@ dependencies = [ [[package]] name = "aster_forge_alloc" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "tikv-jemalloc-ctl", "tracing", @@ -900,7 +901,7 @@ dependencies = [ [[package]] name = "aster_forge_api" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "chrono", "serde", @@ -911,7 +912,7 @@ dependencies = [ [[package]] name = "aster_forge_api_docs_macros" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "proc-macro2", "quote", @@ -921,7 +922,7 @@ dependencies = [ [[package]] name = "aster_forge_audit" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_db", "aster_forge_mail", @@ -933,7 +934,7 @@ dependencies = [ [[package]] name = "aster_forge_cache" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_runtime", "async-trait", @@ -950,7 +951,7 @@ dependencies = [ [[package]] name = "aster_forge_config" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_events", "aster_forge_utils", @@ -970,7 +971,7 @@ dependencies = [ [[package]] name = "aster_forge_crypto" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "argon2 0.5.3", "rand_core 0.6.4", @@ -981,7 +982,7 @@ dependencies = [ [[package]] name = "aster_forge_db" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_api", "aster_forge_config", @@ -1001,7 +1002,7 @@ dependencies = [ [[package]] name = "aster_forge_events" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_utils", "async-trait", @@ -1016,7 +1017,7 @@ dependencies = [ [[package]] name = "aster_forge_external_auth" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_crypto", "aster_forge_utils", @@ -1039,7 +1040,7 @@ dependencies = [ [[package]] name = "aster_forge_file_classification" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "sea-orm", "serde", @@ -1050,7 +1051,7 @@ dependencies = [ [[package]] name = "aster_forge_logging" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "serde", "tracing-appender", @@ -1060,7 +1061,7 @@ dependencies = [ [[package]] name = "aster_forge_mail" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_runtime", "aster_forge_tasks", @@ -1078,7 +1079,7 @@ dependencies = [ [[package]] name = "aster_forge_metrics" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_alloc", "aster_forge_runtime", @@ -1093,7 +1094,7 @@ dependencies = [ [[package]] name = "aster_forge_panic" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "chrono", ] @@ -1101,7 +1102,7 @@ dependencies = [ [[package]] name = "aster_forge_runtime" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_utils", "async-trait", @@ -1118,7 +1119,7 @@ dependencies = [ [[package]] name = "aster_forge_tasks" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "aster_forge_runtime", "aster_forge_utils", @@ -1138,7 +1139,7 @@ dependencies = [ [[package]] name = "aster_forge_utils" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "http 1.4.2", "httpdate", @@ -1157,12 +1158,32 @@ dependencies = [ [[package]] name = "aster_forge_validation" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#19e82ace639c606a6e4082651897daa5586f6e7e" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" dependencies = [ "thiserror 2.0.19", "unicode-normalization", ] +[[package]] +name = "aster_forge_webdav" +version = "0.1.0" +source = "git+https://github.com/AsterCommunity/AsterForge#dcdf1b2da4fcf516d97b5f2ebebbee8a685f8db2" +dependencies = [ + "actix-web", + "aster_forge_utils", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http 1.4.2", + "mime_guess", + "percent-encoding", + "thiserror 2.0.19", + "tracing", + "urlencoding", + "xmltree", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -1220,7 +1241,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -1282,9 +1303,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d" +checksum = "a6b50a43f3ccdf331521c6d6c68b7cc9668b6e09d439ebda9569df5722324d76" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1310,9 +1331,9 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.138.1" +version = "1.139.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa59420755799fec8aac71f562aa37ba82263f5b1926bba8b5d3465d2f7a73f" +checksum = "a159b9721a6a41468f967d1029bece78f410b0beb0594498435deb6ff72bfe48" dependencies = [ "arc-swap", "aws-credential-types", @@ -1508,9 +1529,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" +checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1534,9 +1555,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -1600,9 +1621,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.61.1" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -1612,9 +1633,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -2254,9 +2275,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.3" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -2276,14 +2297,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.3" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3639,9 +3660,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -4218,9 +4239,9 @@ dependencies = [ [[package]] name = "impl-more" -version = "0.3.1" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "indexmap" @@ -4565,9 +4586,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.187" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -5820,9 +5841,9 @@ dependencies = [ [[package]] name = "proc-macro-error-attr3" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" +checksum = "be5bfc63c4dc85083c9daaf7112d0261701d4058677c3bff7f2afc44e30ef3e1" dependencies = [ "proc-macro2", "quote", @@ -5830,14 +5851,14 @@ dependencies = [ [[package]] name = "proc-macro-error3" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" +checksum = "dd0d42490f6b7b143eef32b9e3522e42bf25dadc02c69ed72236f80adb949b5c" dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -6275,7 +6296,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -6517,9 +6538,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.3" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "059dd24c0fe20721639f7acad7b82cd51ec3dd3254ed8cf7a0b7df6c20eaff1c" +checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f" dependencies = [ "aes 0.9.1", "aws-lc-rs", @@ -6751,9 +6772,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -7211,7 +7232,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -7256,7 +7277,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -7912,9 +7933,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -8069,7 +8090,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -8261,9 +8282,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -8289,14 +8310,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -9454,9 +9476,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yansi" diff --git a/Cargo.toml b/Cargo.toml index 15fed1cb1..f6563de2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,6 +123,7 @@ aster_forge_panic = { git = "https://github.com/AsterCommunity/AsterForge", pack aster_forge_runtime = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_runtime" } aster_forge_utils = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_utils" } aster_forge_validation = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_validation" } +aster_forge_webdav = { git = "https://github.com/AsterCommunity/AsterForge", package = "aster_forge_webdav" } async-stream = "0.3" async-trait = "0.1" base64 = "0.22" diff --git a/src/services/content/property.rs b/src/services/content/property.rs index 90ecb8f72..d131c2880 100644 --- a/src/services/content/property.rs +++ b/src/services/content/property.rs @@ -10,7 +10,7 @@ use serde::Serialize; #[cfg(all(debug_assertions, feature = "openapi"))] use utoipa::ToSchema; -pub const SYSTEM_PROPERTY_NAMESPACE_PREFIX: &str = "system."; +use aster_forge_webdav::{is_dav_namespace, is_protected_namespace, is_system_namespace}; #[derive(Debug, Clone, Serialize)] #[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))] @@ -36,18 +36,6 @@ impl From for EntityProperty { } } -pub fn is_system_namespace(namespace: &str) -> bool { - namespace.starts_with(SYSTEM_PROPERTY_NAMESPACE_PREFIX) -} - -pub fn is_dav_namespace(namespace: &str) -> bool { - namespace == "DAV:" -} - -pub fn is_protected_namespace(namespace: &str) -> bool { - is_dav_namespace(namespace) || is_system_namespace(namespace) -} - fn ensure_user_namespace_mutable(namespace: &str) -> Result<()> { if is_dav_namespace(namespace) { return Err(AsterError::auth_forbidden("DAV: namespace is read-only")); diff --git a/src/webdav/auth.rs b/src/webdav/auth.rs index eca9dc243..a2fda4653 100644 --- a/src/webdav/auth.rs +++ b/src/webdav/auth.rs @@ -5,15 +5,15 @@ mod cache; #[path = "auth/rate_limit.rs"] mod rate_limit; -use base64::Engine; use serde::{Deserialize, Serialize}; use crate::api::api_error_code::ApiErrorCode; use crate::db::repository::{user_repo, webdav_account_repo}; -use crate::errors::{AsterError, MapAsterErr, auth_forbidden_with_code}; +use crate::errors::{AsterError, auth_forbidden_with_code}; use crate::runtime::SharedRuntimeState; use crate::services::workspace::storage::WorkspaceStorageScope; use aster_forge_crypto as hash; +use aster_forge_webdav::auth::{BasicAuthParseError, parse_basic_authorization}; /// WebDAV 认证结果 #[derive(Debug)] @@ -110,36 +110,35 @@ pub(crate) async fn authenticate_webdav( request: &actix_web::HttpRequest, state: &impl SharedRuntimeState, ) -> Result { - let auth_header = request - .headers() - .get(actix_web::http::header::AUTHORIZATION) - .and_then(|v: &actix_web::http::header::HeaderValue| v.to_str().ok()) - .ok_or_else(|| AsterError::auth_token_invalid("missing Authorization header"))?; + let credentials = + parse_basic_authorization(request.headers()).map_err(|error| match error { + BasicAuthParseError::Missing => { + AsterError::auth_token_invalid("missing Authorization header") + } + BasicAuthParseError::UnsupportedScheme => { + AsterError::auth_token_invalid("unsupported auth scheme") + } + BasicAuthParseError::InvalidEncoding => { + AsterError::auth_invalid_credentials("invalid base64") + } + BasicAuthParseError::InvalidUtf8 => { + AsterError::auth_invalid_credentials("invalid utf8") + } + BasicAuthParseError::InvalidFormat => { + AsterError::auth_invalid_credentials("invalid basic auth format") + } + })?; - if let Some(basic) = auth_header.strip_prefix("Basic ") { - authenticate_basic(basic.trim(), request, state).await - } else { - Err(AsterError::auth_token_invalid("unsupported auth scheme").into()) - } + authenticate_basic(&credentials.username, &credentials.password, request, state).await } /// Basic Auth: 查 webdav_accounts 表(独立于登录密码) async fn authenticate_basic( - encoded: &str, + username: &str, + password: &str, request: &actix_web::HttpRequest, state: &impl SharedRuntimeState, ) -> Result { - let decoded = base64::engine::general_purpose::STANDARD - .decode(encoded) - .map_aster_err_with(|| AsterError::auth_invalid_credentials("invalid base64"))?; - - let credentials = String::from_utf8(decoded) - .map_aster_err_with(|| AsterError::auth_invalid_credentials("invalid utf8"))?; - - let (username, password) = credentials - .split_once(':') - .ok_or_else(|| AsterError::auth_invalid_credentials("invalid basic auth format"))?; - if let Some(cached) = cache::load_auth(state, username, password).await { tracing::debug!(username_hash = %cache::username_cache_component(username), "webdav auth cache hit"); return Ok(WebdavAuthResult { diff --git a/src/webdav/dav.rs b/src/webdav/dav.rs deleted file mode 100644 index cc1dbb6db..000000000 --- a/src/webdav/dav.rs +++ /dev/null @@ -1,439 +0,0 @@ -//! WebDAV 子模块:`dav`。 - -use std::collections::HashMap; -use std::future::Future; -use std::io::SeekFrom; -use std::pin::Pin; -use std::time::{Duration, SystemTime}; - -use bytes::{Buf, Bytes}; -use futures::Stream; -use http::StatusCode; -use xmltree::Element; - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct DavPath { - raw: String, - decoded: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DavPathError { - InvalidEncoding, - PathEscape, -} - -impl std::fmt::Display for DavPathError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidEncoding => f.write_str("invalid WebDAV path encoding"), - Self::PathEscape => f.write_str("WebDAV path escapes the mount root"), - } - } -} - -impl std::error::Error for DavPathError {} - -impl DavPath { - pub fn new(path: &str) -> Result { - let raw = ensure_leading_slash(path); - let decoded = urlencoding::decode(&raw) - .map_err(|_| DavPathError::InvalidEncoding)? - .into_owned(); - let raw = clean_decoded_path(&decoded)?; - let decoded = raw.as_bytes().to_vec(); - Ok(Self { raw, decoded }) - } - - pub fn root() -> Self { - Self { - raw: "/".to_string(), - decoded: b"/".to_vec(), - } - } - - pub fn as_bytes(&self) -> &[u8] { - &self.decoded - } - - pub fn as_str(&self) -> &str { - &self.raw - } - - pub fn is_collection(&self) -> bool { - self.raw == "/" || self.raw.ends_with('/') - } -} - -fn ensure_leading_slash(path: &str) -> String { - if path.is_empty() || path == "/" { - return "/".to_string(); - } - - let mut normalized = path.to_string(); - if !normalized.starts_with('/') { - normalized.insert(0, '/'); - } - normalized -} - -fn clean_decoded_path(path: &str) -> Result { - let mut segments = Vec::new(); - let mut is_collection = false; - - for (index, segment) in path.split('/').enumerate() { - match segment { - "" => { - if index > 0 { - is_collection = true; - } - } - "." => { - is_collection = true; - } - ".." => { - if segments.pop().is_none() { - return Err(DavPathError::PathEscape); - } - is_collection = true; - } - segment => { - segments.push(segment); - is_collection = false; - } - } - } - - if segments.is_empty() { - return Ok("/".to_string()); - } - - let mut cleaned = format!("/{}", segments.join("/")); - if is_collection && !cleaned.ends_with('/') { - cleaned.push('/'); - } - Ok(cleaned) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FsError { - NotFound, - Forbidden, - GeneralFailure, - Exists, - InsufficientStorage, - TooLarge, - BadRequest, -} - -impl std::fmt::Display for FsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NotFound => f.write_str("not found"), - Self::Forbidden => f.write_str("forbidden"), - Self::GeneralFailure => f.write_str("general failure"), - Self::Exists => f.write_str("already exists"), - Self::InsufficientStorage => f.write_str("insufficient storage"), - Self::TooLarge => f.write_str("too large"), - Self::BadRequest => f.write_str("bad request"), - } - } -} - -impl std::error::Error for FsError {} - -pub type FsResult = Result; -pub type FsFuture<'a, T> = Pin> + Send + 'a>>; -pub type FsStream = Pin> + Send>>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReadDirMeta { - Data, -} - -#[derive(Debug, Clone, Default)] -pub struct OpenOptions { - pub read: bool, - pub write: bool, - pub append: bool, - pub truncate: bool, - pub create: bool, - pub create_new: bool, - pub size: Option, - pub checksum: Option, -} - -impl OpenOptions { - pub fn read() -> Self { - Self { - read: true, - ..Self::default() - } - } - - pub fn write() -> Self { - Self { - write: true, - ..Self::default() - } - } -} - -pub trait DavMetaData: Send + Sync { - fn len(&self) -> u64; - fn modified(&self) -> FsResult; - fn is_dir(&self) -> bool; - fn etag(&self) -> Option; - fn content_type(&self) -> Option<&str> { - None - } - fn created(&self) -> FsResult; - fn property_entity(&self) -> Option<(crate::types::EntityType, i64)> { - None - } - - fn is_empty(&self) -> bool { - self.len() == 0 - } - - fn is_file(&self) -> bool { - !self.is_dir() - } -} - -pub trait DavDirEntry: Send { - fn name(&self) -> Vec; - fn metadata<'a>(&'a self) -> FsFuture<'a, Box>; -} - -pub trait DavFile: Send { - fn metadata<'a>(&'a mut self) -> FsFuture<'a, Box>; - fn read_bytes(&mut self, count: usize) -> FsFuture<'_, Bytes>; - fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()>; - fn write_buf(&mut self, buf: Box) -> FsFuture<'_, ()>; - fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64>; - fn flush(&mut self) -> FsFuture<'_, ()>; -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DavProp { - pub name: String, - pub prefix: Option, - pub namespace: Option, - pub xml: Option>, -} - -pub trait DavFileSystem: Send + Sync { - fn open<'a>( - &'a self, - path: &'a DavPath, - options: OpenOptions, - ) -> FsFuture<'a, Box>; - fn read_dir<'a>( - &'a self, - path: &'a DavPath, - meta: ReadDirMeta, - ) -> FsFuture<'a, FsStream>>; - fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box>; - fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()>; - fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()>; - fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()>; - fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()>; - fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()>; - - fn get_quota(&self) -> FsFuture<'_, (u64, Option)> { - Box::pin(async { Ok((0, None)) }) - } - - fn have_props<'a>( - &'a self, - _path: &'a DavPath, - ) -> Pin + Send + 'a>> { - Box::pin(async { false }) - } - - fn get_props<'a>( - &'a self, - _path: &'a DavPath, - _do_content: bool, - ) -> FsFuture<'a, Vec> { - Box::pin(async { Ok(Vec::new()) }) - } - - fn get_props_many<'a>( - &'a self, - paths: &'a [DavPath], - do_content: bool, - ) -> FsFuture<'a, HashMap>> { - Box::pin(async move { - let mut result = HashMap::with_capacity(paths.len()); - for path in paths { - result.insert(path.clone(), self.get_props(path, do_content).await?); - } - Ok(result) - }) - } - - fn get_props_many_for_entities<'a>( - &'a self, - targets: &'a [(DavPath, crate::types::EntityType, i64)], - do_content: bool, - ) -> FsFuture<'a, HashMap>> { - Box::pin(async move { - let paths = targets - .iter() - .map(|(path, _, _)| path.clone()) - .collect::>(); - self.get_props_many(&paths, do_content).await - }) - } - - fn patch_props<'a>( - &'a self, - _path: &'a DavPath, - _patches: Vec<(bool, DavProp)>, - ) -> FsFuture<'a, Vec<(StatusCode, DavProp)>> { - Box::pin(async { Ok(Vec::new()) }) - } -} - -#[derive(Debug, Clone)] -pub struct DavLock { - pub token: String, - pub path: Box, - pub principal: Option, - pub owner: Option>, - pub timeout_at: Option, - pub timeout: Option, - pub shared: bool, - pub deep: bool, -} - -pub type LsFuture<'a, T> = Pin + Send + 'a>>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DavLockPreflightError { - LimitExceeded, - GeneralFailure, -} - -#[derive(Debug, Clone)] -pub enum DavLockError { - Conflict(DavLock), - LimitExceeded, - Backend, -} - -pub trait DavLockSystem: Send + Sync { - fn prepare_lock(&self, _path: &DavPath) -> LsFuture<'_, Result<(), DavLockPreflightError>> { - Box::pin(async { Ok(()) }) - } - - fn lock( - &self, - path: &DavPath, - principal: Option<&str>, - owner: Option<&Element>, - timeout: Option, - shared: bool, - deep: bool, - ) -> LsFuture<'_, Result>; - - fn unlock(&self, path: &DavPath, token: &str) -> LsFuture<'_, Result<(), ()>>; - - fn refresh( - &self, - path: &DavPath, - token: &str, - timeout: Option, - ) -> LsFuture<'_, Result>; - - fn check( - &self, - path: &DavPath, - principal: Option<&str>, - ignore_principal: bool, - deep: bool, - submitted_tokens: &[String], - ) -> LsFuture<'_, Result<(), DavLock>>; - - fn discover(&self, path: &DavPath) -> LsFuture<'_, Vec>; - - fn discover_many<'a>( - &'a self, - paths: &'a [DavPath], - ) -> LsFuture<'a, HashMap>> { - Box::pin(async move { - let mut result = HashMap::with_capacity(paths.len()); - for path in paths { - result.insert(path.clone(), self.discover(path).await); - } - result - }) - } - - fn conflicting_locks(&self, path: &DavPath, deep: bool) -> LsFuture<'_, Vec>; - - fn delete(&self, path: &DavPath) -> LsFuture<'_, Result<(), ()>>; -} - -#[cfg(test)] -mod tests { - use super::{DavPath, DavPathError}; - - #[test] - fn dav_path_collapses_dot_segments_before_cache_and_resolution() { - let path = DavPath::new("/projects/./docs/reports/../q1.txt").unwrap(); - assert_eq!(path.as_str(), "/projects/docs/q1.txt"); - assert_eq!(path.as_bytes(), b"/projects/docs/q1.txt"); - - let collection = DavPath::new("/projects/./docs/").unwrap(); - assert_eq!(collection.as_str(), "/projects/docs/"); - assert!(collection.is_collection()); - - let relative = DavPath::new("projects/./docs").unwrap(); - assert_eq!(relative.as_str(), "/projects/docs"); - } - - #[test] - fn dav_path_preserves_collection_alias_after_dot_segments() { - let dot_alias = DavPath::new("/projects/docs/.").unwrap(); - assert_eq!(dot_alias.as_str(), "/projects/docs/"); - assert!(dot_alias.is_collection()); - - let parent_alias = DavPath::new("/projects/docs/reports/..").unwrap(); - assert_eq!(parent_alias.as_str(), "/projects/docs/"); - assert!(parent_alias.is_collection()); - - let encoded_parent_alias = DavPath::new("/projects/docs/reports/%2e%2e").unwrap(); - assert_eq!(encoded_parent_alias.as_str(), "/projects/docs/"); - assert!(encoded_parent_alias.is_collection()); - } - - #[test] - fn dav_path_rejects_dot_dot_escape_after_percent_decoding() { - assert!(matches!( - DavPath::new("/../secret.txt"), - Err(DavPathError::PathEscape) - )); - assert!(matches!( - DavPath::new("/projects/../../secret.txt"), - Err(DavPathError::PathEscape) - )); - assert!(matches!( - DavPath::new("/%2e%2e/secret.txt"), - Err(DavPathError::PathEscape) - )); - } - - #[test] - fn dav_path_allows_internal_dot_dot_without_cache_aliases() { - let path = DavPath::new("/projects/docs/../manuals/file.txt").unwrap(); - assert_eq!(path.as_str(), "/projects/manuals/file.txt"); - assert_eq!(path.as_bytes(), b"/projects/manuals/file.txt"); - - let encoded = DavPath::new("/projects/docs/%2e%2e/manuals/file.txt").unwrap(); - assert_eq!(encoded.as_str(), "/projects/manuals/file.txt"); - assert_eq!(encoded.as_bytes(), b"/projects/manuals/file.txt"); - } -} diff --git a/src/webdav/db_lock_system.rs b/src/webdav/db_lock_system.rs index 3256a99aa..bfec81392 100644 --- a/src/webdav/db_lock_system.rs +++ b/src/webdav/db_lock_system.rs @@ -15,11 +15,15 @@ use crate::runtime::{PrimaryAppState, SharedRuntimeState}; use crate::services::ops::audit::{self, AuditContext}; use crate::services::workspace::storage::WorkspaceStorageScope; use crate::types::EntityType; -use crate::webdav::dav::{ +use crate::webdav::path_resolver::{self, ResolvedNode}; +use aster_forge_webdav::dav::{ DavLock, DavLockError, DavLockPreflightError, DavLockSystem, DavPath, LsFuture, }; -use crate::webdav::parse_webdav_element; -use crate::webdav::path_resolver::{self, ResolvedNode}; +use aster_forge_webdav::lock_paths::{ + lock_path_ancestors as path_ancestors, lock_path_is_under, lock_paths_overlap, + normalized_lock_path as normalize_path, unlock_request_targets_lock_scope, +}; +use aster_forge_webdav::parse_webdav_element; const DISCOVER_MANY_ANCESTOR_CHUNK_SIZE: usize = 500; @@ -606,36 +610,6 @@ impl DavLockSystem for DbLockSystem { // ── Helpers ───────────────────────────────────────────────────────── -fn normalize_path(path: &DavPath) -> String { - let raw = String::from_utf8_lossy(path.as_bytes()).to_string(); - if raw.is_empty() || raw == "/" { - "/".to_string() - } else { - raw - } -} - -fn path_ancestors(path: &str) -> Vec { - let mut ancestors = vec!["/".to_string()]; - let trimmed = path.trim_start_matches('/'); - let mut current = String::from("/"); - for seg in trimmed.split('/') { - if seg.is_empty() { - continue; - } - current.push_str(seg); - current.push('/'); - if current != "/" { - ancestors.push(current.clone()); - } - } - if path != "/" && !path.ends_with('/') { - ancestors.push(path.to_string()); - } - ancestors.dedup(); - ancestors -} - /// 从 WebDAV 路径解析出 (entity_type, entity_id) async fn resolve_path_to_entity( db: &C, @@ -705,47 +679,6 @@ async fn delete_lock_and_sync_flag(db: &C, lock: &resource_l } } -fn lock_paths_overlap( - existing_path: &str, - existing_deep: bool, - requested_path: &str, - requested_deep: bool, -) -> bool { - if existing_path == requested_path { - return true; - } - if path_is_ancestor(existing_path, requested_path) { - return existing_deep; - } - if path_is_ancestor(requested_path, existing_path) { - return requested_deep; - } - false -} - -fn lock_path_is_under(parent: &str, child: &str) -> bool { - parent == child || path_is_ancestor(parent, child) -} - -fn unlock_request_targets_lock_scope(lock_path: &str, lock_deep: bool, request_path: &str) -> bool { - lock_path == request_path || lock_deep && path_is_ancestor(lock_path, request_path) -} - -fn path_is_ancestor(parent: &str, child: &str) -> bool { - if parent == child { - return false; - } - if parent == "/" { - return child.starts_with('/'); - } - if parent.ends_with('/') { - return child.starts_with(parent); - } - child - .strip_prefix(parent) - .is_some_and(|suffix| suffix.starts_with('/')) -} - fn lock_timeout_at( now: chrono::DateTime, timeout: Option, diff --git a/src/webdav/deltav.rs b/src/webdav/deltav.rs deleted file mode 100644 index a84ad2dbe..000000000 --- a/src/webdav/deltav.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! RFC3253 DeltaV 最小子集 — 版本历史查询 -//! -//! 自研 WebDAV handler 在这里承接 REPORT / VERSION-CONTROL, -//! 利用已有的 file_versions 表返回最小 DeltaV 能力。 - -use actix_web::HttpResponse; -use actix_web::http::{StatusCode, Uri}; -use aster_forge_utils::xml::{XmlSafetyError, XmlSafetyPolicy, xml_root_local_name}; -use sea_orm::DatabaseConnection; -use xmltree::Element; - -use crate::db::repository::{file_repo, user_repo, version_repo}; -use crate::webdav::auth::WebdavAuthResult; -use crate::webdav::dav::DavPath; -use crate::webdav::path_resolver::{self, ResolvedNode}; -use crate::webdav::{href_for_relative, responses, xml_response}; - -/// 处理 REPORT 方法(cadaver `history` 发送 `DAV:version-tree`) -pub(crate) async fn handle_report( - uri: &Uri, - body_bytes: &[u8], - db: &DatabaseConnection, - auth: &WebdavAuthResult, - prefix: &str, -) -> HttpResponse { - // 解析 XML body,确认是 version-tree 报告 - let root_name = match xml_root_local_name(body_bytes, XmlSafetyPolicy::untrusted()) { - Ok(root_name) => root_name, - Err(XmlSafetyError::ExternalEntity) => return responses::no_external_entities(), - Err( - XmlSafetyError::TooDeep | XmlSafetyError::Malformed | XmlSafetyError::InvalidPolicy, - ) => { - return error_response(StatusCode::BAD_REQUEST, "Invalid XML body"); - } - }; - - if root_name != "version-tree" { - return error_response( - StatusCode::NOT_IMPLEMENTED, - &format!("Unsupported REPORT type: {root_name}"), - ); - } - - // 从 URI 中去掉 prefix 得到文件路径 - let path_str = uri.path(); - let relative = path_str.strip_prefix(prefix).unwrap_or(path_str); - - // 构造一个 DavPath 用于路径解析 - let dav_path = match DavPath::new(relative) { - Ok(p) => p, - Err(_) => return error_response(StatusCode::BAD_REQUEST, "Invalid path"), - }; - - let node = - match path_resolver::resolve_path_in_scope(db, auth.scope, &dav_path, auth.root_folder_id) - .await - { - Ok(n) => n, - Err(_) => return error_response(StatusCode::NOT_FOUND, "Not Found"), - }; - - let file = match node { - ResolvedNode::File(f) => f, - _ => { - return error_response( - StatusCode::CONFLICT, - "Version history is only available for files", - ); - } - }; - let decoded_relative = String::from_utf8_lossy(dav_path.as_bytes()).into_owned(); - - // 查版本列表 - let versions = match version_repo::find_by_file_id(db, file.id).await { - Ok(v) => v, - Err(_) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to query versions", - ); - } - }; - - // 查用户名 - let creator = match file.created_by_user_id { - Some(user_id) => user_repo::find_by_id(db, user_id) - .await - .map(|u| u.username) - .unwrap_or_else(|_| file.created_by_username.clone()), - None => file.created_by_username.clone(), - }; - let creator = if creator.is_empty() { - "unknown".to_string() - } else { - creator - }; - - // 查当前版本的 blob 信息 - let current_blob = file_repo::find_blob_by_id(db, file.blob_id).await.ok(); - - // 构建 207 Multi-Status XML - let mut multistatus = Element::new("D:multistatus"); - multistatus - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - - // 当前版本(活跃版本) - if let Some(blob) = ¤t_blob { - let href = href_for_relative(prefix, &decoded_relative); - let response = - build_version_response(&href, "current", blob.size, &file.updated_at, &creator); - multistatus - .children - .push(xmltree::XMLNode::Element(response)); - } - - // 历史版本 - // 批量查 blob 信息 - let blob_ids: Vec = versions.iter().map(|v| v.blob_id).collect(); - let blobs = file_repo::find_blobs_by_ids(db, &blob_ids) - .await - .unwrap_or_default(); - - for ver in &versions { - let size = blobs.get(&ver.blob_id).map(|b| b.size).unwrap_or(ver.size); - - let href = format!( - "{}?v={}", - href_for_relative(prefix, &decoded_relative), - ver.version - ); - let response = build_version_response( - &href, - &format!("V{}", ver.version), - size, - &ver.created_at, - &creator, - ); - multistatus - .children - .push(xmltree::XMLNode::Element(response)); - } - - xml_response(multistatus, StatusCode::MULTI_STATUS) -} - -/// 处理 VERSION-CONTROL 方法(所有文件自动版本控制,直接返回 200) -pub(crate) async fn handle_version_control( - uri: &Uri, - db: &DatabaseConnection, - auth: &WebdavAuthResult, - prefix: &str, -) -> HttpResponse { - let path_str = uri.path(); - let relative = path_str.strip_prefix(prefix).unwrap_or(path_str); - - let dav_path = match DavPath::new(relative) { - Ok(p) => p, - Err(_) => return error_response(StatusCode::BAD_REQUEST, "Invalid path"), - }; - - match path_resolver::resolve_path_in_scope(db, auth.scope, &dav_path, auth.root_folder_id).await - { - Ok(ResolvedNode::File(_)) => { - responses::text(StatusCode::OK, "Already under version control") - } - Ok(_) => error_response( - StatusCode::METHOD_NOT_ALLOWED, - "Only files support version control", - ), - Err(_) => error_response(StatusCode::NOT_FOUND, "Not Found"), - } -} - -/// 构建单个版本的 `` 元素 -fn build_version_response( - href: &str, - version_name: &str, - size: i64, - modified: &chrono::DateTime, - creator: &str, -) -> Element { - let mut response = Element::new("D:response"); - - // - let mut href_el = Element::new("D:href"); - href_el - .children - .push(xmltree::XMLNode::Text(href.to_string())); - response.children.push(xmltree::XMLNode::Element(href_el)); - - // - let mut propstat = Element::new("D:propstat"); - - let mut prop = Element::new("D:prop"); - - // - let mut vname = Element::new("D:version-name"); - vname - .children - .push(xmltree::XMLNode::Text(version_name.to_string())); - prop.children.push(xmltree::XMLNode::Element(vname)); - - // - let mut cname = Element::new("D:creator-displayname"); - cname - .children - .push(xmltree::XMLNode::Text(creator.to_string())); - prop.children.push(xmltree::XMLNode::Element(cname)); - - // - let mut clen = Element::new("D:getcontentlength"); - clen.children.push(xmltree::XMLNode::Text(size.to_string())); - prop.children.push(xmltree::XMLNode::Element(clen)); - - // - let mut lmod = Element::new("D:getlastmodified"); - let rfc2822 = modified.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); - lmod.children.push(xmltree::XMLNode::Text(rfc2822)); - prop.children.push(xmltree::XMLNode::Element(lmod)); - - propstat.children.push(xmltree::XMLNode::Element(prop)); - - // - let mut status = Element::new("D:status"); - status - .children - .push(xmltree::XMLNode::Text("HTTP/1.1 200 OK".to_string())); - propstat.children.push(xmltree::XMLNode::Element(status)); - - response.children.push(xmltree::XMLNode::Element(propstat)); - - response -} - -fn error_response(status: StatusCode, msg: &str) -> HttpResponse { - responses::text(status, msg) -} diff --git a/src/webdav/dir_entry.rs b/src/webdav/dir_entry.rs index aa4f8d282..19dee170c 100644 --- a/src/webdav/dir_entry.rs +++ b/src/webdav/dir_entry.rs @@ -1,8 +1,8 @@ //! WebDAV 子模块:`dir_entry`。 use crate::entities::{file, file_blob, folder}; -use crate::webdav::dav::{DavDirEntry, DavMetaData, FsFuture}; use crate::webdav::metadata::AsterDavMeta; +use aster_forge_webdav::dav::{DavDirEntry, DavMetaData, FsFuture}; #[derive(Debug)] pub struct AsterDavDirEntry { diff --git a/src/webdav/file/mod.rs b/src/webdav/file/mod.rs index 1911aefed..80e27174f 100644 --- a/src/webdav/file/mod.rs +++ b/src/webdav/file/mod.rs @@ -13,9 +13,9 @@ use crate::runtime::{PrimaryAppState, SharedRuntimeState}; use crate::services::ops::audit::{self, AuditContext}; use crate::services::workspace::storage::{self, WorkspaceStorageScope}; use crate::storage::StorageDriver; -use crate::webdav::dav::{DavFile, DavMetaData, FsError, FsFuture}; use crate::webdav::metadata::AsterDavMeta; use aster_forge_utils::numbers::{i64_to_u64, u64_to_i64, usize_to_u64}; +use aster_forge_webdav::dav::{DavFile, DavMetaData, FsError, FsFuture}; const RELAY_DIRECT_BUFFER_SIZE: usize = 64 * 1024; diff --git a/src/webdav/file/tests.rs b/src/webdav/file/tests.rs index 01bbbe56d..c867cc93f 100644 --- a/src/webdav/file/tests.rs +++ b/src/webdav/file/tests.rs @@ -9,9 +9,9 @@ use crate::services::mail::sender; use crate::storage::BlobMetadata; use crate::storage::{DriverRegistry, PolicySnapshot, StorageDriver, StreamUploadDriver}; use crate::types::{DriverType, UserRole, UserStatus}; -use crate::webdav::dav::DavFile; use aster_forge_cache as cache; use aster_forge_cache::CacheConfig; +use aster_forge_webdav::dav::DavFile; use async_trait::async_trait; use bytes::Bytes; use chrono::Utc; diff --git a/src/webdav/fs/mod.rs b/src/webdav/fs/mod.rs index b10d40ab2..93d75bf80 100644 --- a/src/webdav/fs/mod.rs +++ b/src/webdav/fs/mod.rs @@ -3,14 +3,15 @@ use aster_forge_db::transaction; use std::{collections::HashMap, pin::Pin, time::Instant}; -use futures::stream; +use bytes::Bytes; +use futures::{StreamExt, stream}; use tokio::io::AsyncRead; +use tokio_util::io::ReaderStream; use crate::db::repository::{file_repo, folder_repo, property_repo, team_repo, user_repo}; use crate::entities::{file, file_blob}; use crate::runtime::{PrimaryAppState, SharedRuntimeState}; use crate::services::{ - content::property, events::storage_change, files::{file as file_ops, folder}, ops::audit::{self, AuditContext}, @@ -18,10 +19,6 @@ use crate::services::{ workspace::storage::WorkspaceStorageScope, }; use crate::types::EntityType; -use crate::webdav::dav::{ - DavDirEntry, DavFile, DavFileSystem, DavMetaData, DavPath, DavProp, FsError, FsFuture, - FsStream, OpenOptions, ReadDirMeta, -}; use crate::webdav::dir_entry::AsterDavDirEntry; use crate::webdav::download_audit::{ WebdavDownloadAuditIdentity, WebdavDownloadRequestKind, record_download, @@ -31,6 +28,13 @@ use crate::webdav::metadata::AsterDavMeta; use crate::webdav::path_resolver::{self, ResolvedNode}; use aster_forge_api::NullablePatch; use aster_forge_utils::numbers::i64_to_u64; +use aster_forge_webdav::dav::{ + DavDirEntry, DavFile, DavFileSystem, DavMetaData, DavPath, DavProp, DavPropertyTarget, + DavResourceKind, FsError, FsFuture, FsStream, OpenOptions, ReadDirMeta, +}; +use aster_forge_webdav::is_protected_namespace; + +const DOWNLOAD_CHUNK_SIZE: usize = 64 * 1024; /// AsterDrive WebDAV 文件系统,per-account workspace 实例。 #[derive(Clone)] @@ -43,10 +47,9 @@ pub struct AsterDavFs { audit_ctx: AuditContext, } -pub(crate) struct AsterDavDownloadFile { - pub(crate) file: file::Model, - pub(crate) blob: file_blob::Model, - pub(crate) meta: AsterDavMeta, +struct AsterDavDownloadFile { + file: file::Model, + blob: file_blob::Model, } impl std::fmt::Debug for AsterDavFs { @@ -97,7 +100,7 @@ impl AsterDavFs { self.scope } - pub(crate) async fn resolve_download_target( + async fn resolve_download_target( &self, path: &DavPath, ) -> Result, FsError> { @@ -119,12 +122,10 @@ impl AsterDavFs { let blob = file_repo::find_blob_by_id(self.state.reader_db(), file.blob_id) .await .map_err(|_| FsError::GeneralFailure)?; - let meta = AsterDavMeta::from_file(&file, &blob); - - Ok(Some(AsterDavDownloadFile { file, blob, meta })) + Ok(Some(AsterDavDownloadFile { file, blob })) } - pub(crate) async fn open_download_stream_for_file( + async fn open_download_stream_for_file( &self, file: &file::Model, blob: &file_blob::Model, @@ -397,6 +398,26 @@ impl DavFileSystem for AsterDavFs { }) } + fn open_download_stream<'a>( + &'a self, + path: &'a DavPath, + offset: Option, + length: Option, + ) -> FsFuture<'a, FsStream> { + Box::pin(async move { + let target = self + .resolve_download_target(path) + .await? + .ok_or(FsError::Forbidden)?; + let reader = self + .open_download_stream_for_file(&target.file, &target.blob, offset, length) + .await?; + let stream = ReaderStream::with_capacity(reader, DOWNLOAD_CHUNK_SIZE) + .map(|result| result.map_err(|_| FsError::GeneralFailure)); + Ok(Box::pin(stream) as FsStream) + }) + } + fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { Box::pin(async move { match path_resolver::resolve_path_cached_in_scope( @@ -666,6 +687,10 @@ impl DavFileSystem for AsterDavFs { }) } + fn copy_dir_shallow<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> { + Box::pin(async move { AsterDavFs::copy_dir_shallow(self, from, to).await }) + } + fn get_quota(&self) -> FsFuture<'_, (u64, Option)> { Box::pin(async move { let (storage_used, storage_quota) = match self.scope { @@ -715,7 +740,7 @@ impl DavFileSystem for AsterDavFs { .map(|props| { props .iter() - .any(|prop| !property::is_protected_namespace(&prop.namespace)) + .any(|prop| !is_protected_namespace(&prop.namespace)) }) .unwrap_or(false) }) @@ -761,7 +786,7 @@ impl DavFileSystem for AsterDavFs { .map_err(|_| FsError::GeneralFailure)?; let mut props_by_target: HashMap<(EntityType, i64), Vec> = HashMap::new(); for prop in props { - if property::is_protected_namespace(&prop.namespace) { + if is_protected_namespace(&prop.namespace) { continue; } props_by_target @@ -783,14 +808,14 @@ impl DavFileSystem for AsterDavFs { fn get_props_many_for_entities<'a>( &'a self, - targets: &'a [(DavPath, EntityType, i64)], + targets: &'a [(DavPath, DavPropertyTarget)], do_content: bool, ) -> FsFuture<'a, HashMap>> { Box::pin(async move { let mut target_paths: HashMap<(EntityType, i64), Vec> = HashMap::new(); let mut entity_targets = Vec::with_capacity(targets.len()); - for (path, entity_type, entity_id) in targets { - let target = (*entity_type, *entity_id); + for (path, target) in targets { + let target = (entity_type_from_dav_target(*target), target.id); target_paths.entry(target).or_default().push(path.clone()); entity_targets.push(target); } @@ -800,7 +825,7 @@ impl DavFileSystem for AsterDavFs { .map_err(|_| FsError::GeneralFailure)?; let mut props_by_target: HashMap<(EntityType, i64), Vec> = HashMap::new(); for prop in props { - if property::is_protected_namespace(&prop.namespace) { + if is_protected_namespace(&prop.namespace) { continue; } props_by_target @@ -834,7 +859,7 @@ impl DavFileSystem for AsterDavFs { let mut protected_failure = false; for (_, prop) in &patches { let ns = prop.namespace.as_deref().unwrap_or(""); - if property::is_protected_namespace(ns) { + if is_protected_namespace(ns) { protected_failure = true; break; } @@ -845,7 +870,7 @@ impl DavFileSystem for AsterDavFs { .into_iter() .map(|(_, prop)| { let ns = prop.namespace.as_deref().unwrap_or(""); - let status = if property::is_protected_namespace(ns) { + let status = if is_protected_namespace(ns) { http::StatusCode::FORBIDDEN } else { http::StatusCode::FAILED_DEPENDENCY @@ -917,13 +942,20 @@ impl DavFileSystem for AsterDavFs { } } +fn entity_type_from_dav_target(target: DavPropertyTarget) -> EntityType { + match target.kind { + DavResourceKind::File => EntityType::File, + DavResourceKind::Folder => EntityType::Folder, + } +} + fn entity_props_to_dav_props( props: Vec, do_content: bool, ) -> Vec { props .into_iter() - .filter(|p| !property::is_protected_namespace(&p.namespace)) + .filter(|p| !is_protected_namespace(&p.namespace)) .map(|p| entity_prop_to_dav_prop(p, do_content)) .collect() } @@ -989,7 +1021,7 @@ async fn copy_visible_entity_properties( .map_err(|_| FsError::GeneralFailure)?; for prop in props { - if property::is_protected_namespace(&prop.namespace) { + if is_protected_namespace(&prop.namespace) { continue; } property_repo::upsert( diff --git a/src/webdav/handler_tests/mod.rs b/src/webdav/handler_tests/mod.rs index e68a93879..97cd28ad1 100644 --- a/src/webdav/handler_tests/mod.rs +++ b/src/webdav/handler_tests/mod.rs @@ -9,15 +9,16 @@ use crate::types::{ DriverType, ObjectStorageUploadStrategy, StoragePolicyOptions, StoredStoragePolicyAllowedTypes, UserRole, UserStatus, serialize_storage_policy_options, }; -use crate::webdav::dav::{DavLock, DavLockError, DavLockSystem, LsFuture}; use crate::webdav::fs::AsterDavFs; -use crate::webdav::props::handle_propfind; -use crate::webdav::transfer::{handle_get_head, handle_put}; use actix_web::body::to_bytes; use actix_web::http::{StatusCode, header}; use actix_web::{FromRequest, web}; use aster_forge_cache as cache; use aster_forge_cache::CacheConfig; +use aster_forge_webdav::dav::{DavLock, DavLockError, DavLockSystem, LsFuture}; +use aster_forge_webdav::encode_href; +use aster_forge_webdav::props::handle_propfind; +use aster_forge_webdav::transfer::{handle_get_head, handle_put}; use async_trait::async_trait; use chrono::Utc; use migration::Migrator; @@ -210,7 +211,7 @@ struct NoopLockSystem; impl DavLockSystem for NoopLockSystem { fn lock( &self, - _path: &crate::webdav::dav::DavPath, + _path: &aster_forge_webdav::dav::DavPath, _principal: Option<&str>, _owner: Option<&xmltree::Element>, _timeout: Option, @@ -222,7 +223,7 @@ impl DavLockSystem for NoopLockSystem { fn unlock( &self, - _path: &crate::webdav::dav::DavPath, + _path: &aster_forge_webdav::dav::DavPath, _token: &str, ) -> LsFuture<'_, Result<(), ()>> { Box::pin(async { Ok(()) }) @@ -230,7 +231,7 @@ impl DavLockSystem for NoopLockSystem { fn refresh( &self, - _path: &crate::webdav::dav::DavPath, + _path: &aster_forge_webdav::dav::DavPath, _token: &str, _timeout: Option, ) -> LsFuture<'_, Result> { @@ -239,7 +240,7 @@ impl DavLockSystem for NoopLockSystem { fn check( &self, - _path: &crate::webdav::dav::DavPath, + _path: &aster_forge_webdav::dav::DavPath, _principal: Option<&str>, _ignore_principal: bool, _deep: bool, @@ -248,19 +249,19 @@ impl DavLockSystem for NoopLockSystem { Box::pin(async { Ok(()) }) } - fn discover(&self, _path: &crate::webdav::dav::DavPath) -> LsFuture<'_, Vec> { + fn discover(&self, _path: &aster_forge_webdav::dav::DavPath) -> LsFuture<'_, Vec> { Box::pin(async { Vec::new() }) } fn conflicting_locks( &self, - _path: &crate::webdav::dav::DavPath, + _path: &aster_forge_webdav::dav::DavPath, _deep: bool, ) -> LsFuture<'_, Vec> { Box::pin(async { Vec::new() }) } - fn delete(&self, _path: &crate::webdav::dav::DavPath) -> LsFuture<'_, Result<(), ()>> { + fn delete(&self, _path: &aster_forge_webdav::dav::DavPath) -> LsFuture<'_, Result<(), ()>> { Box::pin(async { Ok(()) }) } } @@ -574,7 +575,7 @@ async fn propfind_href_is_percent_encoded_and_xml_parseable() { let dav_fs = AsterDavFs::new(state.clone(), user.id, None); let lock_system = NoopLockSystem; - let encoded_uri = format!("/webdav{}", super::encode_href(&format!("/{filename}"))); + let encoded_uri = format!("/webdav{}", encode_href(&format!("/{filename}"))); let req = actix_web::test::TestRequest::default() .method(actix_web::http::Method::from_bytes(b"PROPFIND").expect("valid method")) .uri(&encoded_uri) diff --git a/src/webdav/locks/mod.rs b/src/webdav/locks/mod.rs deleted file mode 100644 index ce5c2f750..000000000 --- a/src/webdav/locks/mod.rs +++ /dev/null @@ -1,485 +0,0 @@ -//! WebDAV LOCK / UNLOCK handlers and lock XML helpers. - -use std::time::Duration; - -use actix_web::http::{StatusCode, header}; -use actix_web::{HttpRequest, HttpResponse}; -use aster_forge_utils::xml::XmlSafetyError; -use xmltree::{Element, XMLNode}; - -use crate::webdav::dav::{ - DavFileSystem, DavLock, DavLockError, DavLockPreflightError, DavLockSystem, FsError, - OpenOptions, -}; -use crate::webdav::protocol::{self, Depth}; -use crate::webdav::{ - child_elements, dav_element, encode_href, fs, fs_error_response, href_for_dav_path, - lock_token_matches_request_uri_response, lock_token_submitted_response, parse_webdav_element, - request_origin, request_path, responses, text_element, -}; - -const MAX_LOCK_DURATION_SECS: u64 = 604_800; - -pub(crate) async fn handle_lock( - req: &HttpRequest, - dav_fs: &fs::AsterDavFs, - lock_system: &dyn DavLockSystem, - prefix: &str, - body: &[u8], -) -> HttpResponse { - let (path, _) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - - if body.is_empty() { - let timeout = match parse_timeout(req.headers()) { - Ok(timeout) => timeout, - Err(resp) => return resp, - }; - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - let request_href = href_for_dav_path(prefix, &path); - let tokens = protocol::submitted_lock_tokens_for_path( - req.headers(), - &request_href, - &request_scheme, - &request_host, - ); - if tokens.len() != 1 { - return responses::bad_request(); - } - if lock_system - .check(&path, None, false, false, &tokens) - .await - .is_err() - { - return responses::precondition_failed(); - } - let lock = match lock_system.refresh(&path, &tokens[0], timeout).await { - Ok(lock) => lock, - Err(_) => return responses::precondition_failed(), - }; - return lock_response(lock, StatusCode::OK, prefix, false); - } - - let timeout = match parse_timeout(req.headers()) { - Ok(timeout) => timeout, - Err(resp) => return resp, - }; - let depth = match protocol::parse_lock_depth(req.headers()) { - Ok(depth) => depth, - Err(resp) => return resp, - }; - - let tree = match parse_webdav_element(body) { - Ok(tree) => tree, - Err(XmlSafetyError::ExternalEntity) => return responses::no_external_entities(), - Err( - XmlSafetyError::TooDeep | XmlSafetyError::Malformed | XmlSafetyError::InvalidPolicy, - ) => { - return responses::invalid_xml_body(); - } - }; - if !is_dav_element(&tree, "lockinfo") { - return invalid_lock_body(); - } - - let mut shared = None; - let mut owner = None; - let mut write_lock = false; - for elem in child_elements(&tree) { - match elem.name.as_str() { - "lockscope" if is_dav_element(elem, "lockscope") => { - let children = child_elements(elem).collect::>(); - if children.len() != 1 { - return responses::bad_request(); - } - let scope = children.first().map(|child| child.name.as_str()); - match scope { - Some("exclusive") if is_dav_element(children[0], "exclusive") => { - shared = Some(false) - } - Some("shared") if is_dav_element(children[0], "shared") => shared = Some(true), - _ => return responses::bad_request(), - } - } - "locktype" if is_dav_element(elem, "locktype") => { - let children = child_elements(elem).collect::>(); - if children.len() != 1 || !is_dav_element(children[0], "write") { - return responses::bad_request(); - } - write_lock = true; - } - "owner" if is_dav_element(elem, "owner") => owner = Some(elem.clone()), - _ => return responses::bad_request(), - } - } - if shared.is_none() || !write_lock { - return responses::bad_request(); - } - - if let Err(error) = lock_system.prepare_lock(&path).await { - return match error { - DavLockPreflightError::LimitExceeded => responses::webdav_lock_limit_exceeded(), - DavLockPreflightError::GeneralFailure => { - responses::empty(StatusCode::INTERNAL_SERVER_ERROR) - } - }; - } - - let resource_existed = match ensure_lock_target_exists(dav_fs, &path, depth).await { - Ok(resource_existed) => resource_existed, - Err(err) => return fs_error_response(err), - }; - - let lock = match lock_system - .lock( - &path, - None, - owner.as_ref(), - timeout, - shared.unwrap_or(false), - depth.is_infinity(), - ) - .await - { - Ok(lock) => lock, - Err(DavLockError::Conflict(lock)) => { - return lock_token_submitted_response(StatusCode::LOCKED, prefix, &lock.path); - } - Err(DavLockError::LimitExceeded) => return responses::webdav_lock_limit_exceeded(), - Err(DavLockError::Backend) => return responses::empty(StatusCode::INTERNAL_SERVER_ERROR), - }; - - let status = if resource_existed { - StatusCode::OK - } else { - StatusCode::CREATED - }; - lock_response(lock, status, prefix, true) -} - -pub(crate) async fn handle_unlock( - req: &HttpRequest, - lock_system: &dyn DavLockSystem, - prefix: &str, -) -> HttpResponse { - let (path, _) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - let token = match req.headers().get("Lock-Token") { - Some(token) => match parse_lock_token_header(token) { - Ok(token) => token, - Err(resp) => return resp, - }, - None => return responses::bad_request(), - }; - - match lock_system.unlock(&path, &token).await { - Ok(()) => responses::no_store(StatusCode::NO_CONTENT), - Err(()) => lock_token_matches_request_uri_response(StatusCode::CONFLICT), - } -} - -fn parse_lock_token_header(value: &header::HeaderValue) -> Result { - let raw = value - .to_str() - .map_err(|_| invalid_lock_token_header())? - .trim(); - let Some(token) = raw - .strip_prefix('<') - .and_then(|value| value.strip_suffix('>')) - else { - return Err(invalid_lock_token_header()); - }; - if token.is_empty() || token.contains(['<', '>']) { - return Err(invalid_lock_token_header()); - } - Ok(token.to_string()) -} - -fn parse_timeout(headers: &header::HeaderMap) -> Result, HttpResponse> { - let Some(raw) = headers.get("Timeout") else { - return Ok(Some(Duration::from_secs(MAX_LOCK_DURATION_SECS))); - }; - let raw = raw.to_str().map_err(|_| invalid_timeout_header())?; - for candidate in raw - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if candidate.eq_ignore_ascii_case("Infinite") { - return Ok(Some(Duration::from_secs(MAX_LOCK_DURATION_SECS))); - } - if let Some(seconds) = candidate - .strip_prefix("Second-") - .and_then(|seconds| seconds.parse::().ok()) - { - if seconds > MAX_LOCK_DURATION_SECS { - return Err(invalid_timeout_header()); - } - return Ok(Some(Duration::from_secs(seconds))); - } - } - Err(invalid_timeout_header()) -} - -fn invalid_lock_body() -> HttpResponse { - responses::bad_request_text("Invalid LOCK body") -} - -fn invalid_lock_token_header() -> HttpResponse { - responses::bad_request_text("Invalid Lock-Token header") -} - -fn invalid_timeout_header() -> HttpResponse { - responses::bad_request_text("Invalid Timeout header") -} - -async fn ensure_lock_target_exists( - dav_fs: &fs::AsterDavFs, - path: &crate::webdav::dav::DavPath, - depth: Depth, -) -> Result { - let _ = depth; - match dav_fs.metadata(path).await { - Ok(_) => Ok(true), - Err(FsError::NotFound) if !path.is_collection() => { - let mut file = dav_fs - .open( - path, - OpenOptions { - write: true, - create: true, - truncate: true, - size: Some(0), - ..OpenOptions::default() - }, - ) - .await?; - file.flush().await?; - Ok(false) - } - Err(FsError::NotFound) => Err(FsError::NotFound), - Err(err) => Err(err), - } -} - -pub(crate) fn supportedlock_element() -> Element { - let mut supported = dav_element("supportedlock"); - - let mut exclusive = dav_element("lockentry"); - let mut exclusive_scope = dav_element("lockscope"); - exclusive_scope - .children - .push(XMLNode::Element(dav_element("exclusive"))); - exclusive.children.push(XMLNode::Element(exclusive_scope)); - let mut exclusive_type = dav_element("locktype"); - exclusive_type - .children - .push(XMLNode::Element(dav_element("write"))); - exclusive.children.push(XMLNode::Element(exclusive_type)); - supported.children.push(XMLNode::Element(exclusive)); - - let mut shared = dav_element("lockentry"); - let mut shared_scope = dav_element("lockscope"); - shared_scope - .children - .push(XMLNode::Element(dav_element("shared"))); - shared.children.push(XMLNode::Element(shared_scope)); - let mut shared_type = dav_element("locktype"); - shared_type - .children - .push(XMLNode::Element(dav_element("write"))); - shared.children.push(XMLNode::Element(shared_type)); - supported.children.push(XMLNode::Element(shared)); - - supported -} - -pub(crate) fn lockdiscovery_element(locks: &[DavLock], prefix: &str) -> Element { - let mut discovery = dav_element("lockdiscovery"); - for lock in locks { - discovery - .children - .push(XMLNode::Element(active_lock_element(lock, prefix))); - } - discovery -} - -fn active_lock_element(lock: &DavLock, prefix: &str) -> Element { - let mut active = dav_element("activelock"); - - let mut lockscope = dav_element("lockscope"); - lockscope.children.push(XMLNode::Element(if lock.shared { - dav_element("shared") - } else { - dav_element("exclusive") - })); - active.children.push(XMLNode::Element(lockscope)); - - let mut locktype = dav_element("locktype"); - locktype - .children - .push(XMLNode::Element(dav_element("write"))); - active.children.push(XMLNode::Element(locktype)); - - if let Some(owner) = &lock.owner { - active.children.push(XMLNode::Element((**owner).clone())); - } - - let mut timeout = dav_element("timeout"); - let timeout_value = lock - .timeout - .map(|duration| format!("Second-{}", duration.as_secs())) - .unwrap_or_else(|| "Infinite".to_string()); - timeout.children.push(XMLNode::Text(timeout_value)); - active.children.push(XMLNode::Element(timeout)); - - let mut token = dav_element("locktoken"); - token.children.push(XMLNode::Element(text_element( - "D:href", - &encode_href(&lock.token), - ))); - active.children.push(XMLNode::Element(token)); - - let mut depth = dav_element("depth"); - depth.children.push(XMLNode::Text(if lock.deep { - "Infinity".to_string() - } else { - "0".to_string() - })); - active.children.push(XMLNode::Element(depth)); - - let mut lockroot = dav_element("lockroot"); - lockroot.children.push(XMLNode::Element(text_element( - "D:href", - &href_for_dav_path(prefix, &lock.path), - ))); - active.children.push(XMLNode::Element(lockroot)); - - active -} - -fn lock_response( - lock: DavLock, - status: StatusCode, - prefix: &str, - include_lock_token_header: bool, -) -> HttpResponse { - let mut prop = dav_element("prop"); - prop.attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - prop.children.push(XMLNode::Element(lockdiscovery_element( - std::slice::from_ref(&lock), - prefix, - ))); - - let (mut response, body) = match responses::xml_response_builder(prop, status) { - Ok(parts) => parts, - Err(resp) => return resp, - }; - - if include_lock_token_header { - response.insert_header(("Lock-Token", format!("<{}>", lock.token))); - } - response.body(body) -} - -fn is_dav_element(element: &Element, local_name: &str) -> bool { - element.name == local_name && element.namespace.as_deref() == Some("DAV:") -} - -#[cfg(test)] -mod tests { - use super::{MAX_LOCK_DURATION_SECS, parse_timeout}; - use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue}; - use std::time::Duration; - - fn timeout_header(value: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("timeout"), - HeaderValue::from_str(value).expect("timeout test header should parse"), - ); - headers - } - - #[test] - fn parse_timeout_defaults_to_server_max_duration() { - let headers = HeaderMap::new(); - - let timeout = parse_timeout(&headers) - .expect("missing Timeout should use default") - .expect("missing Timeout should not create an infinite lock"); - - assert_eq!(timeout, Duration::from_secs(MAX_LOCK_DURATION_SECS)); - } - - #[test] - fn parse_timeout_clamps_infinite_to_server_max_duration() { - let headers = timeout_header("Infinite"); - - let timeout = parse_timeout(&headers) - .expect("Infinite should be accepted") - .expect("Infinite should be clamped instead of stored as None"); - - assert_eq!(timeout, Duration::from_secs(MAX_LOCK_DURATION_SECS)); - } - - #[test] - fn parse_timeout_accepts_exact_server_max_duration() { - let headers = timeout_header("Second-604800"); - - let timeout = parse_timeout(&headers) - .expect("exact max timeout should be accepted") - .expect("exact max timeout should produce a duration"); - - assert_eq!(timeout, Duration::from_secs(MAX_LOCK_DURATION_SECS)); - } - - #[test] - fn parse_timeout_rejects_above_server_max_duration() { - let headers = timeout_header("Second-604801"); - - assert!( - parse_timeout(&headers).is_err(), - "timeout above server maximum must be rejected" - ); - } - - #[test] - fn parse_timeout_rejects_u64_values_too_large_for_chrono() { - let headers = timeout_header("Second-9223372036854775808"); - - assert!( - parse_timeout(&headers).is_err(), - "overflow-sized timeout must be rejected before DB conversion" - ); - } - - #[test] - fn parse_timeout_skips_unsupported_candidate_before_valid_timeout() { - let headers = timeout_header("nonsense, Second-60"); - - let timeout = parse_timeout(&headers) - .expect("later valid Timeout candidate should be accepted") - .expect("valid Timeout candidate should produce a duration"); - - assert_eq!(timeout, Duration::from_secs(60)); - } -} diff --git a/src/webdav/metadata.rs b/src/webdav/metadata.rs index 65d1a39d1..b8e68d462 100644 --- a/src/webdav/metadata.rs +++ b/src/webdav/metadata.rs @@ -3,8 +3,7 @@ use std::time::SystemTime; use crate::entities::{file, file_blob, folder}; -use crate::types::EntityType; -use crate::webdav::dav::{DavMetaData, FsResult}; +use aster_forge_webdav::dav::{DavMetaData, DavPropertyTarget, DavResourceKind, FsResult}; /// 将 chrono DateTimeUtc 转换为 SystemTime fn to_system_time(dt: chrono::DateTime) -> SystemTime { @@ -23,7 +22,7 @@ pub struct AsterDavMeta { created: SystemTime, etag: Option, content_type: Option, - property_entity: Option<(EntityType, i64)>, + property_entity: Option, } impl AsterDavMeta { @@ -47,7 +46,7 @@ impl AsterDavMeta { created: to_system_time(folder.created_at), etag: Some(format!("dir-{}", folder.updated_at.timestamp())), content_type: None, - property_entity: Some((EntityType::Folder, folder.id)), + property_entity: Some(DavPropertyTarget::new(DavResourceKind::Folder, folder.id)), } } @@ -59,7 +58,7 @@ impl AsterDavMeta { created: to_system_time(file.created_at), etag: Some(file_etag(file)), content_type: Some(file.mime_type.clone()), - property_entity: Some((EntityType::File, file.id)), + property_entity: Some(DavPropertyTarget::new(DavResourceKind::File, file.id)), } } @@ -71,7 +70,7 @@ impl AsterDavMeta { created: to_system_time(file.created_at), etag: Some(file_etag(file)), content_type: Some(file.mime_type.clone()), - property_entity: Some((EntityType::File, file.id)), + property_entity: Some(DavPropertyTarget::new(DavResourceKind::File, file.id)), } } } @@ -114,7 +113,7 @@ impl DavMetaData for AsterDavMeta { Ok(self.created) } - fn property_entity(&self) -> Option<(EntityType, i64)> { + fn property_entity(&self) -> Option { self.property_entity } } diff --git a/src/webdav/mod.rs b/src/webdav/mod.rs index baabc764b..b381a14ec 100644 --- a/src/webdav/mod.rs +++ b/src/webdav/mod.rs @@ -1,74 +1,35 @@ -//! WebDAV 模块导出。 +//! AsterDrive WebDAV product adapters and route integration. pub mod auth; -pub mod dav; pub mod db_lock_system; -pub mod deltav; pub mod dir_entry; mod download_audit; pub mod file; pub mod fs; -mod locks; pub mod metadata; pub mod path_resolver; -mod props; -mod protocol; -mod resources; -mod responses; pub mod system_file; -mod transfer; +mod version_provider; -use actix_web::http::{StatusCode, header}; use actix_web::{HttpRequest, HttpResponse, web}; -use aster_forge_utils::xml::{XmlSafetyError, XmlSafetyPolicy, validate_xml_input}; -use futures::StreamExt; -use std::io::Cursor; -use xmltree::{Element, XMLNode}; use crate::config::{NetworkTrustConfig, RateLimitConfig, WebDavConfig}; use crate::runtime::{PrimaryAppState, SharedRuntimeState}; use crate::services::ops::audit; -use crate::webdav::dav::{DavLockSystem, DavPath}; use aster_forge_utils::numbers::u64_to_usize; +use aster_forge_webdav::handler::WebDavHandler; +use aster_forge_webdav::responses; -pub(crate) use responses::{ - fs_error_response, lock_token_matches_request_uri_response, lock_token_submitted_element, - lock_token_submitted_response, xml_bytes, xml_response, -}; +const AUTH_REALM: &str = "AsterDrive WebDAV"; -pub(crate) fn encode_href(path: &str) -> String { - use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; - - const PATH_SET: &AsciiSet = &CONTROLS - .add(b' ') - .add(b'"') - .add(b'#') - .add(b'<') - .add(b'>') - .add(b'?') - .add(b'`') - .add(b'{') - .add(b'}') - .add(b'&') - .add(b'\'') - .add(b'+') - .add(b'%'); - - utf8_percent_encode(path, PATH_SET).to_string() -} - -pub(crate) fn parse_webdav_element(body: &[u8]) -> Result { - validate_xml_input(body, XmlSafetyPolicy::untrusted())?; - Element::parse(Cursor::new(body)).map_err(|_| XmlSafetyError::Malformed) -} - -/// WebDAV 共享状态(单例) +/// Route-level WebDAV configuration shared by authenticated requests. pub struct WebDavState { pub prefix: String, pub xml_payload_limit: usize, } -/// WebDAV handler — 所有协议方法都由自研分发层处理 +/// Authenticates the AsterDrive account, constructs product adapters, and then +/// delegates the complete protocol exchange to Forge. pub async fn webdav_handler( req: HttpRequest, mut payload: web::Payload, @@ -80,20 +41,19 @@ pub async fn webdav_handler( .runtime_config() .get_bool_or("webdav_enabled", true) { - return responses::webdav_disabled(); + return responses::service_unavailable_text("WebDAV is disabled"); } let auth_result = match auth::authenticate_webdav(&req, state.get_ref()).await { Ok(result) => result, Err(auth::WebdavAuthError::RateLimited { retry_after }) => { - return responses::unauthorized_retry_after(retry_after); + return responses::unauthorized_retry_after(AUTH_REALM, retry_after); } - Err(auth::WebdavAuthError::Rejected) => return responses::unauthorized(), + Err(auth::WebdavAuthError::Rejected) => return responses::unauthorized(AUTH_REALM), }; let audit_info = audit::AuditRequestInfo::from_request(&req); let audit_ctx = audit_info.to_context(auth_result.scope.actor_user_id()); - let dav_fs = fs::AsterDavFs::new_with_audit( state.get_ref().clone(), Some(auth_result.account_id), @@ -107,366 +67,24 @@ pub async fn webdav_handler( auth_result.root_folder_id, audit_ctx, ); - - match req.method().as_str() { - "OPTIONS" => match resources::ensure_empty_body(&mut payload).await { - Ok(()) => handle_options(), - Err(resp) => resp, - }, - "REPORT" => match collect_xml_payload(&mut payload, webdav.xml_payload_limit).await { - Ok(body) => { - deltav::handle_report( - req.uri(), - &body, - state.get_ref().writer_db(), - &auth_result, - &webdav.prefix, - ) - .await - } - Err(resp) => resp, - }, - "VERSION-CONTROL" => { - deltav::handle_version_control( - req.uri(), - state.get_ref().writer_db(), - &auth_result, - &webdav.prefix, - ) - .await - } - "PROPFIND" => match collect_xml_payload(&mut payload, webdav.xml_payload_limit).await { - Ok(body) => { - props::handle_propfind(&req, &dav_fs, lock_system.as_ref(), &webdav.prefix, &body) - .await - } - Err(resp) => resp, - }, - "PROPPATCH" => match collect_xml_payload(&mut payload, webdav.xml_payload_limit).await { - Ok(body) => { - props::handle_proppatch(&req, &dav_fs, lock_system.as_ref(), &webdav.prefix, &body) - .await - } - Err(resp) => resp, - }, - "GET" => { - transfer::handle_get_head(&req, &dav_fs, lock_system.as_ref(), &webdav.prefix, false) - .await - } - "HEAD" => { - transfer::handle_get_head(&req, &dav_fs, lock_system.as_ref(), &webdav.prefix, true) - .await - } - "PUT" => { - let system_file_policy = system_file::SystemFileBlockPolicy::from_runtime_config( - state.get_ref().runtime_config(), - ); - transfer::handle_put( - &req, - &dav_fs, - lock_system.as_ref(), - &webdav.prefix, - &system_file_policy, - &mut payload, - ) - .await - } - "MKCOL" => { - let system_file_policy = system_file::SystemFileBlockPolicy::from_runtime_config( - state.get_ref().runtime_config(), - ); - resources::handle_mkcol( - &req, - &dav_fs, - lock_system.as_ref(), - &webdav.prefix, - &system_file_policy, - &mut payload, - ) - .await - } - "DELETE" => match resources::ensure_empty_body(&mut payload).await { - Ok(()) => { - resources::handle_delete(&req, &dav_fs, lock_system.as_ref(), &webdav.prefix).await - } - Err(resp) => resp, - }, - "COPY" => match resources::ensure_empty_body(&mut payload).await { - Ok(()) => { - let system_file_policy = system_file::SystemFileBlockPolicy::from_runtime_config( - state.get_ref().runtime_config(), - ); - resources::handle_copy_move( - &req, - &dav_fs, - lock_system.as_ref(), - &webdav.prefix, - &system_file_policy, - false, - ) - .await - } - Err(resp) => resp, - }, - "MOVE" => match resources::ensure_empty_body(&mut payload).await { - Ok(()) => { - let system_file_policy = system_file::SystemFileBlockPolicy::from_runtime_config( - state.get_ref().runtime_config(), - ); - resources::handle_copy_move( - &req, - &dav_fs, - lock_system.as_ref(), - &webdav.prefix, - &system_file_policy, - true, - ) - .await - } - Err(resp) => resp, - }, - "LOCK" => match collect_xml_payload(&mut payload, webdav.xml_payload_limit).await { - Ok(body) => { - locks::handle_lock(&req, &dav_fs, lock_system.as_ref(), &webdav.prefix, &body).await - } - Err(resp) => resp, - }, - "UNLOCK" => match resources::ensure_empty_body(&mut payload).await { - Ok(()) => locks::handle_unlock(&req, lock_system.as_ref(), &webdav.prefix).await, - Err(resp) => resp, - }, - _ => responses::method_not_allowed(allow_header_value()), - } -} - -async fn collect_xml_payload( - payload: &mut web::Payload, - max_len: usize, -) -> Result, HttpResponse> { - let mut data = Vec::with_capacity(max_len.min(4096)); - while let Some(chunk) = payload.next().await { - let chunk = match chunk { - Ok(chunk) => chunk, - Err(_) => return Err(responses::request_body_read_error()), - }; - let next_len = match data.len().checked_add(chunk.len()) { - Some(next_len) => next_len, - None => return Err(responses::xml_body_too_large()), - }; - if next_len > max_len { - return Err(responses::xml_body_too_large()); - } - data.extend_from_slice(&chunk); - } - Ok(data) -} - -fn handle_options() -> HttpResponse { - HttpResponse::Ok() - .insert_header((header::ALLOW, allow_header_value())) - .insert_header(("DAV", "1, 2, version-control")) - .insert_header(("MS-Author-Via", "DAV")) - .finish() -} - -pub(crate) fn ensure_system_file_name_allowed( - system_file_policy: &system_file::SystemFileBlockPolicy, - relative: &str, -) -> Result<(), HttpResponse> { - let name = display_name(relative); - if name.is_empty() || !system_file_policy.is_blocked_name(name) { - return Ok(()); - } - - Err(responses::system_file_name_blocked()) -} - -pub(crate) async fn ensure_unlocked( - lock_system: &dyn DavLockSystem, - path: &DavPath, - deep: bool, - prefix: &str, - headers: &header::HeaderMap, - request_scheme: &str, - request_host: &str, -) -> Result<(), HttpResponse> { - for lock in lock_system.conflicting_locks(path, deep).await { - let lock_href = href_for_dav_path(prefix, &lock.path); - let submitted_tokens = protocol::submitted_lock_tokens_for_path( - headers, - &lock_href, - request_scheme, - request_host, - ); - if !submitted_tokens.iter().any(|token| token == &lock.token) { - return Err(lock_token_submitted_response( - StatusCode::LOCKED, - prefix, - &lock.path, - )); - } - } - - Ok(()) -} - -pub(crate) async fn ensure_parent_unlocked( - lock_system: &dyn DavLockSystem, - relative: &str, - prefix: &str, - headers: &header::HeaderMap, - request_scheme: &str, - request_host: &str, -) -> Result<(), HttpResponse> { - let Some(parent) = parent_relative_path(relative) else { - return Ok(()); - }; - let parent_path = DavPath::new(&parent).map_err(|_| responses::invalid_request_path())?; - ensure_unlocked( - lock_system, - &parent_path, - false, - prefix, - headers, - request_scheme, - request_host, + let version_provider = + version_provider::AsterDavVersionProvider::new(state.get_ref().writer_db(), &auth_result); + let name_policy = + system_file::SystemFileBlockPolicy::from_runtime_config(state.get_ref().runtime_config()); + + WebDavHandler::new( + &webdav.prefix, + webdav.xml_payload_limit, + &dav_fs, + lock_system.as_ref(), + &version_provider, + &name_policy, ) + .handle(&req, &mut payload) .await } -pub(crate) fn request_path( - req: &HttpRequest, - prefix: &str, -) -> Result<(DavPath, String), HttpResponse> { - decode_relative_path(req.path().strip_prefix(prefix).unwrap_or(req.path())) -} - -pub(crate) fn request_origin(req: &HttpRequest) -> (String, String) { - let connection = req.connection_info(); - ( - connection.scheme().to_string(), - connection.host().to_string(), - ) -} - -pub(crate) fn decode_relative_path(relative: &str) -> Result<(DavPath, String), HttpResponse> { - let normalized = normalize_relative_path(relative); - let path = DavPath::new(&normalized).map_err(|_| responses::invalid_request_path())?; - let decoded = decoded_path_string(&path); - Ok((path, decoded)) -} - -fn normalize_relative_path(path: &str) -> String { - if path.is_empty() || path == "/" { - return "/".to_string(); - } - if path.starts_with('/') { - path.to_string() - } else { - format!("/{path}") - } -} - -pub(crate) fn dav_element(name: &str) -> Element { - Element::new(&format!("D:{name}")) -} - -pub(crate) fn text_element(tag: &str, text: &str) -> Element { - let mut element = Element::new(tag); - element.children.push(XMLNode::Text(text.to_string())); - element -} - -pub(crate) fn status_element(status: StatusCode) -> Element { - text_element( - "D:status", - &format!( - "HTTP/1.1 {} {}", - status.as_u16(), - status.canonical_reason().unwrap_or("Unknown"), - ), - ) -} - -pub(crate) fn child_elements(element: &Element) -> impl Iterator { - element.children.iter().filter_map(|child| match child { - XMLNode::Element(element) => Some(element), - _ => None, - }) -} - -pub(crate) fn child_relative_path(parent: &str, name: &[u8], is_dir: bool) -> String { - let name = String::from_utf8_lossy(name); - let mut relative = if parent == "/" { - format!("/{name}") - } else if parent.ends_with('/') { - format!("{parent}{name}") - } else { - format!("{parent}/{name}") - }; - if is_dir && !relative.ends_with('/') { - relative.push('/'); - } - relative -} - -pub(crate) fn decoded_path_string(path: &DavPath) -> String { - String::from_utf8_lossy(path.as_bytes()).into_owned() -} - -pub(crate) fn href_for_relative(prefix: &str, relative: &str) -> String { - let href = if relative == "/" { - format!("{prefix}/") - } else { - format!("{prefix}{relative}") - }; - encode_href(&href) -} - -pub(crate) fn href_for_dav_path(prefix: &str, path: &DavPath) -> String { - href_for_relative(prefix, &decoded_path_string(path)) -} - -pub(crate) fn display_name(relative: &str) -> &str { - if relative == "/" { - "" - } else { - relative - .trim_end_matches('/') - .rsplit('/') - .next() - .unwrap_or("") - } -} - -pub(crate) fn parent_relative_path(relative: &str) -> Option { - if relative == "/" { - return None; - } - let trimmed = relative.trim_end_matches('/'); - let segments: Vec<_> = trimmed - .split('/') - .filter(|segment| !segment.is_empty()) - .collect(); - if segments.len() <= 1 { - return Some("/".to_string()); - } - Some(format!("/{}/", segments[..segments.len() - 1].join("/"))) -} - -pub(crate) fn format_creation_date(time: std::time::SystemTime) -> String { - chrono::DateTime::::from(time).to_rfc3339() -} - -fn allow_header_value() -> &'static str { - "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, VERSION-CONTROL" -} - -pub(crate) fn multi_status() -> StatusCode { - StatusCode::MULTI_STATUS -} - -/// 注册 WebDAV 路由 +/// Registers the AsterDrive route, authentication protection, and payload cap. pub fn configure( cfg: &mut web::ServiceConfig, webdav_config: &WebDavConfig, @@ -533,28 +151,3 @@ pub fn configure_with_rate_limit( #[cfg(test)] mod handler_tests; - -#[cfg(test)] -mod tests { - use super::{XmlSafetyError, parse_webdav_element}; - - #[test] - fn parse_webdav_element_rejects_probe_depth_before_xmltree_parse() { - const PROBE_DEPTH: usize = 30_000; - - let mut body = String::with_capacity(64 + PROBE_DEPTH * 7); - body.push_str(r#""#); - for _ in 0..PROBE_DEPTH { - body.push_str(""); - } - for _ in 0..PROBE_DEPTH { - body.push_str(""); - } - body.push_str(""); - - assert_eq!( - parse_webdav_element(body.as_bytes()), - Err(XmlSafetyError::TooDeep) - ); - } -} diff --git a/src/webdav/path_resolver.rs b/src/webdav/path_resolver.rs index 82c61f3bc..86294aee1 100644 --- a/src/webdav/path_resolver.rs +++ b/src/webdav/path_resolver.rs @@ -10,8 +10,8 @@ use crate::entities::{file, folder}; use crate::errors::AsterError; use crate::runtime::SharedRuntimeState; use crate::services::workspace::storage::WorkspaceStorageScope; -use crate::webdav::dav::{DavPath, FsError}; use aster_forge_crypto as hash; +use aster_forge_webdav::dav::{DavPath, FsError}; pub(crate) use cache::{WEBDAV_PARENT_CACHE_PREFIX, WEBDAV_PATH_CACHE_PREFIX}; diff --git a/src/webdav/props/mod.rs b/src/webdav/props/mod.rs deleted file mode 100644 index a0cf8e5c9..000000000 --- a/src/webdav/props/mod.rs +++ /dev/null @@ -1,1377 +0,0 @@ -//! WebDAV PROPFIND / PROPPATCH handlers. - -use std::collections::BTreeSet; -use std::collections::{BTreeMap, HashMap}; -use std::time::Instant; - -use actix_web::http::StatusCode; -use actix_web::{HttpRequest, HttpResponse}; -use aster_forge_utils::http_validators::format_http_date; -use aster_forge_utils::xml::XmlSafetyError; -use futures::{StreamExt, pin_mut}; -use xmltree::{Element, XMLNode}; - -use crate::services::content::property; -use crate::webdav::dav::{ - DavFileSystem, DavLock, DavLockSystem, DavMetaData, DavPath, DavProp, FsError, ReadDirMeta, -}; -use crate::webdav::locks::{lockdiscovery_element, supportedlock_element}; -use crate::webdav::protocol::{self, Depth}; -use crate::webdav::responses; -use crate::webdav::{ - child_elements, child_relative_path, dav_element, display_name, ensure_unlocked, - format_creation_date, fs_error_response, href_for_dav_path, href_for_relative, multi_status, - parse_webdav_element, request_origin, request_path, status_element, text_element, xml_bytes, - xml_response, -}; - -#[derive(Clone)] -struct RequestedProp { - name: String, - namespace: Option, - prefix: Option, -} - -enum PropfindKind { - AllProp { include: Vec }, - PropName, - Prop(Vec), -} - -struct PropfindResource { - path: Option, - relative: String, - meta: Box, -} - -type PropKey = (String, Option); -type PropKeySet = BTreeSet; - -#[derive(Default)] -struct PropfindPreload { - dead_props: HashMap>, - locks: HashMap>, -} - -impl RequestedProp { - fn from(element: &Element) -> Self { - Self { - name: element.name.clone(), - namespace: element.namespace.clone(), - prefix: element.prefix.clone(), - } - } - - fn empty_element(&self) -> Element { - let prefix = self - .prefix - .as_deref() - .unwrap_or_else(|| default_prefix(self.namespace.as_deref())); - let tag = if self.namespace.is_some() { - format!("{prefix}:{}", self.name) - } else { - self.name.clone() - }; - let mut element = Element::new(&tag); - if let Some(namespace) = &self.namespace - && should_declare_namespace(prefix, namespace) - { - element - .attributes - .insert(format!("xmlns:{prefix}"), namespace.clone()); - } - element - } - - fn matches(&self, prop: &DavProp) -> bool { - self.name == prop.name && self.namespace.as_deref() == prop.namespace.as_deref() - } - - fn key(&self) -> (String, Option) { - (self.name.clone(), self.namespace.clone()) - } - - fn is_system_namespace(&self) -> bool { - self.namespace - .as_deref() - .is_some_and(property::is_system_namespace) - } -} - -impl PropfindPreload { - async fn load( - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - request_kind: &PropfindKind, - resources: &[PropfindResource], - ) -> Result { - let mut preload = Self::default(); - - if propfind_kind_needs_dead_props(request_kind) { - let targets = resources - .iter() - .filter(|resource| !is_root_resource(resource)) - .filter_map(|resource| { - let (entity_type, entity_id) = resource.meta.property_entity()?; - Some((resource.path.clone()?, entity_type, entity_id)) - }) - .collect::>(); - preload.dead_props = dav_fs - .get_props_many_for_entities( - &targets, - propfind_kind_needs_dead_prop_content(request_kind), - ) - .await - .map_err(fs_error_response)?; - } - - if propfind_kind_needs_lockdiscovery(request_kind) { - let paths = resources - .iter() - .filter_map(|resource| resource.path.clone()) - .collect::>(); - preload.locks = lock_system.discover_many(&paths).await; - } - - Ok(preload) - } - - fn dead_props_for(&self, resource: &PropfindResource) -> &[DavProp] { - resource - .path - .as_ref() - .and_then(|path| self.dead_props.get(path)) - .map(Vec::as_slice) - .unwrap_or_default() - } - - fn locks_for(&self, resource: &PropfindResource) -> &[DavLock] { - resource - .path - .as_ref() - .and_then(|path| self.locks.get(path)) - .map(Vec::as_slice) - .unwrap_or_default() - } -} - -pub(crate) async fn handle_propfind( - req: &HttpRequest, - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - prefix: &str, - body: &[u8], -) -> HttpResponse { - let (path, relative) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - let depth = match protocol::parse_propfind_depth(req.headers()) { - Ok(depth) => depth, - Err(resp) => return resp, - }; - let request_kind = match parse_propfind_request(body) { - Ok(kind) => kind, - Err(resp) => return resp, - }; - - let request_started_at = Instant::now(); - let metadata_started_at = Instant::now(); - let root_meta = match dav_fs.metadata(&path).await { - Ok(meta) => meta, - Err(err) => return fs_error_response(err), - }; - let metadata_elapsed_ms = metadata_started_at.elapsed().as_millis(); - if depth == Depth::Infinity && root_meta.is_dir() { - return responses::propfind_finite_depth_response(); - } - let collect_started_at = Instant::now(); - let preload_needs_paths = propfind_kind_needs_dead_props(&request_kind) - || propfind_kind_needs_lockdiscovery(&request_kind); - let resources = match collect_propfind_resources( - dav_fs, - &path, - &relative, - depth, - root_meta, - preload_needs_paths, - ) - .await - { - Ok(resources) => resources, - Err(err) => return fs_error_response(err), - }; - let collect_elapsed_ms = collect_started_at.elapsed().as_millis(); - let resource_count = resources.len(); - - let preload_started_at = Instant::now(); - let preload = match PropfindPreload::load(dav_fs, lock_system, &request_kind, &resources).await - { - Ok(preload) => preload, - Err(resp) => return resp, - }; - let preload_elapsed_ms = preload_started_at.elapsed().as_millis(); - - let mut multistatus = dav_element("multistatus"); - multistatus - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - - let render_started_at = Instant::now(); - for resource in resources { - let response = match build_propfind_response(prefix, &request_kind, &preload, resource) { - Ok(response) => response, - Err(resp) => return resp, - }; - multistatus.children.push(XMLNode::Element(response)); - } - tracing::debug!( - depth = ?depth, - kind = propfind_kind_label(&request_kind), - resource_count, - metadata_elapsed_ms, - collect_elapsed_ms, - preload_elapsed_ms, - render_elapsed_ms = render_started_at.elapsed().as_millis(), - total_elapsed_ms = request_started_at.elapsed().as_millis(), - "WebDAV PROPFIND completed" - ); - - xml_response(multistatus, multi_status()) -} - -pub(crate) async fn handle_proppatch( - req: &HttpRequest, - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - prefix: &str, - body: &[u8], -) -> HttpResponse { - let (path, _) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - if path.as_str() == "/" { - // The WebDAV mount root is a virtual listing boundary, not a persisted - // file/folder entity. Dead properties are intentionally unavailable - // there instead of being backed by an implicit root row. - return responses::unsupported_root_proppatch(); - } - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if let Err(resp) = ensure_unlocked( - lock_system, - &path, - false, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - let patches = match parse_proppatch_request(body) { - Ok(patches) => patches, - Err(resp) => return resp, - }; - - let results = match dav_fs.patch_props(&path, patches).await { - Ok(results) => results, - Err(err) => return fs_error_response(err), - }; - - let mut multistatus = dav_element("multistatus"); - multistatus - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - - let mut response = dav_element("response"); - response.children.push(XMLNode::Element(text_element( - "D:href", - &href_for_dav_path(prefix, &path), - ))); - - let mut groups: BTreeMap> = BTreeMap::new(); - for (status, prop) in results { - groups.entry(status.as_u16()).or_default().push(prop); - } - - for (status_code, props) in groups { - let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - let mut propstat = dav_element("propstat"); - let mut prop = dav_element("prop"); - for item in props { - prop.children - .push(XMLNode::Element(prop_element(&item, None))); - } - propstat.children.push(XMLNode::Element(prop)); - propstat - .children - .push(XMLNode::Element(status_element(status))); - response.children.push(XMLNode::Element(propstat)); - } - - multistatus.children.push(XMLNode::Element(response)); - xml_response(multistatus, multi_status()) -} - -fn parse_propfind_request(body: &[u8]) -> Result { - if body.is_empty() { - return Ok(PropfindKind::AllProp { - include: Vec::new(), - }); - } - - let root = match parse_webdav_element(body) { - Ok(root) => root, - Err(XmlSafetyError::ExternalEntity) => return Err(responses::no_external_entities()), - Err( - XmlSafetyError::TooDeep | XmlSafetyError::Malformed | XmlSafetyError::InvalidPolicy, - ) => { - return Err(responses::invalid_xml_body()); - } - }; - if !is_dav_element(&root, "propfind") { - return Err(invalid_propfind_body()); - } - - let mut kind = None; - let mut include = Vec::new(); - let mut include_seen = false; - - for child in child_elements(&root) { - if is_dav_element(child, "propname") { - if kind.is_some() { - return Err(invalid_propfind_body()); - } - kind = Some(PropfindKind::PropName); - } else if is_dav_element(child, "allprop") { - if kind.is_some() { - return Err(invalid_propfind_body()); - } - kind = Some(PropfindKind::AllProp { - include: Vec::new(), - }); - } else if is_dav_element(child, "include") { - if include_seen { - return Err(invalid_propfind_body()); - } - include_seen = true; - include.extend(child_elements(child).map(RequestedProp::from)); - } else if is_dav_element(child, "prop") { - if kind.is_some() { - return Err(invalid_propfind_body()); - } - let props = child_elements(child).map(RequestedProp::from).collect(); - kind = Some(PropfindKind::Prop(props)); - } else { - // RFC 4918 section 17 requires unknown XML elements, including - // their complete subtrees, to be processed as if they were absent. - // Only direct DAV: children above can select PROPFIND behavior. - continue; - } - } - - match kind { - Some(PropfindKind::AllProp { .. }) => Ok(PropfindKind::AllProp { include }), - Some(kind) => { - if !include_seen { - Ok(kind) - } else { - Err(invalid_propfind_body()) - } - } - None => Err(invalid_propfind_body()), - } -} - -fn is_dav_element(element: &Element, local_name: &str) -> bool { - element.name == local_name && element.namespace.as_deref() == Some("DAV:") -} - -fn parse_proppatch_request(body: &[u8]) -> Result, HttpResponse> { - let root = match parse_webdav_element(body) { - Ok(root) => root, - Err(XmlSafetyError::ExternalEntity) => return Err(responses::no_external_entities()), - Err( - XmlSafetyError::TooDeep | XmlSafetyError::Malformed | XmlSafetyError::InvalidPolicy, - ) => { - return Err(responses::invalid_xml_body()); - } - }; - if root.name != "propertyupdate" { - return Err(invalid_proppatch_body()); - } - - let root_lang = xml_lang_value(&root).map(str::to_string); - let mut patches = Vec::new(); - for action in child_elements(&root) { - let action_lang = xml_lang_value(action).or(root_lang.as_deref()); - let set = match action.name.as_str() { - "set" => true, - "remove" => false, - _ => return Err(invalid_proppatch_body()), - }; - let mut action_children = child_elements(action); - let Some(prop_container) = action_children.next() else { - return Err(invalid_proppatch_body()); - }; - if prop_container.name != "prop" || action_children.next().is_some() { - return Err(invalid_proppatch_body()); - } - let prop_container_lang = xml_lang_value(prop_container).or(action_lang); - for prop in child_elements(prop_container) { - let inherited_lang = xml_lang_value(prop).or(prop_container_lang); - patches.push((set, prop_from_xml(prop, inherited_lang))); - } - } - if patches.is_empty() { - return Err(invalid_proppatch_body()); - } - Ok(patches) -} - -fn invalid_propfind_body() -> HttpResponse { - responses::bad_request_text("Invalid PROPFIND body") -} - -fn invalid_proppatch_body() -> HttpResponse { - responses::bad_request_text("Invalid PROPPATCH body") -} - -async fn collect_propfind_resources( - dav_fs: &dyn DavFileSystem, - path: &DavPath, - relative: &str, - depth: Depth, - root_meta: Box, - include_paths: bool, -) -> Result, FsError> { - let root_is_dir = root_meta.is_dir(); - let mut resources = vec![PropfindResource { - path: include_paths.then(|| path.clone()), - relative: relative.to_string(), - meta: root_meta, - }]; - - if depth == Depth::One && root_is_dir { - let entries = dav_fs.read_dir(path, ReadDirMeta::Data).await?; - pin_mut!(entries); - while let Some(entry) = entries.next().await { - let entry = entry?; - let meta = entry.metadata().await?; - let child_relative = child_relative_path(relative, &entry.name(), meta.is_dir()); - let child_path = if include_paths { - Some(DavPath::new(&child_relative).map_err(|_| FsError::GeneralFailure)?) - } else { - None - }; - resources.push(PropfindResource { - path: child_path, - relative: child_relative, - meta, - }); - } - } - - Ok(resources) -} - -fn propfind_kind_label(kind: &PropfindKind) -> &'static str { - match kind { - PropfindKind::AllProp { .. } => "allprop", - PropfindKind::PropName => "propname", - PropfindKind::Prop(_) => "prop", - } -} - -fn build_propfind_response( - prefix: &str, - request_kind: &PropfindKind, - preload: &PropfindPreload, - resource: PropfindResource, -) -> Result { - let mut response = dav_element("response"); - response.children.push(XMLNode::Element(text_element( - "D:href", - &href_for_relative(prefix, &resource.relative), - ))); - - let propstats = match request_kind { - PropfindKind::AllProp { include } => { - all_propstat_elements(prefix, &resource, include, preload)? - } - PropfindKind::PropName => { - vec![(StatusCode::OK, prop_name_elements(&resource, preload))] - } - PropfindKind::Prop(requested) => { - requested_prop_elements(prefix, &resource, requested, preload)? - } - }; - - for (status, props) in propstats { - if props.is_empty() { - continue; - } - let mut propstat = dav_element("propstat"); - let mut prop = dav_element("prop"); - for item in props { - prop.children.push(XMLNode::Element(item)); - } - propstat.children.push(XMLNode::Element(prop)); - propstat - .children - .push(XMLNode::Element(status_element(status))); - response.children.push(XMLNode::Element(propstat)); - } - - Ok(response) -} - -fn all_prop_elements( - prefix: &str, - resource: &PropfindResource, - preload: &PropfindPreload, -) -> Result<(Vec, PropKeySet), HttpResponse> { - let mut props = standard_prop_name_list(resource) - .into_iter() - .map(|prop| RequestedProp { - name: prop.to_string(), - namespace: Some("DAV:".to_string()), - prefix: Some("D".to_string()), - }) - .collect::>(); - let mut keys = props - .iter() - .map(RequestedProp::key) - .collect::>(); - let custom_props = preload.dead_props_for(resource); - let mut elements = Vec::new(); - for requested in props.drain(..) { - if let Some(element) = standard_prop_element(prefix, resource, &requested, preload)? { - elements.push(element); - } - } - for prop in custom_props { - keys.insert(dav_prop_key(prop)); - elements.push(prop_element(prop, None)); - } - Ok((elements, keys)) -} - -fn all_propstat_elements( - prefix: &str, - resource: &PropfindResource, - include: &[RequestedProp], - preload: &PropfindPreload, -) -> Result)>, HttpResponse> { - let (all_props, all_prop_keys) = all_prop_elements(prefix, resource, preload)?; - if include.is_empty() { - return Ok(vec![(StatusCode::OK, all_props)]); - } - - let include = include - .iter() - .filter(|prop| !all_prop_keys.contains(&prop.key())) - .cloned() - .collect::>(); - if include.is_empty() { - return Ok(vec![(StatusCode::OK, all_props)]); - } - - let mut result = vec![(StatusCode::OK, all_props)]; - let requested = requested_prop_elements(prefix, resource, &include, preload)?; - for (status, props) in requested { - if !props.is_empty() { - result.push((status, props)); - } - } - Ok(result) -} - -fn prop_name_elements(resource: &PropfindResource, preload: &PropfindPreload) -> Vec { - let mut elements = Vec::new(); - for name in standard_prop_name_list(resource) { - let requested = RequestedProp { - name: name.to_string(), - namespace: Some("DAV:".to_string()), - prefix: Some("D".to_string()), - }; - elements.push(requested.empty_element()); - } - for prop in preload.dead_props_for(resource) { - elements.push(prop_element( - prop, - Some(&RequestedProp { - name: prop.name.clone(), - namespace: prop.namespace.clone(), - prefix: prop.prefix.clone(), - }), - )); - } - elements -} - -fn requested_prop_elements( - prefix: &str, - resource: &PropfindResource, - requested: &[RequestedProp], - preload: &PropfindPreload, -) -> Result)>, HttpResponse> { - let custom_props = preload.dead_props_for(resource); - let mut ok = Vec::new(); - let mut missing = Vec::new(); - - for prop in requested { - if prop.is_system_namespace() { - missing.push(prop.empty_element()); - continue; - } - - if let Some(element) = standard_prop_element(prefix, resource, prop, preload)? { - ok.push(element); - continue; - } - - if let Some(stored) = custom_props - .iter() - .find(|candidate| prop.matches(candidate)) - { - ok.push(prop_element(stored, Some(prop))); - } else { - missing.push(prop.empty_element()); - } - } - - let mut result = Vec::new(); - if !ok.is_empty() { - result.push((StatusCode::OK, ok)); - } - if !missing.is_empty() { - result.push((StatusCode::NOT_FOUND, missing)); - } - Ok(result) -} - -fn requested_props_may_need_dead_lookup(requested: &[RequestedProp]) -> bool { - requested.iter().any(requested_prop_may_be_dead_property) -} - -fn propfind_kind_needs_dead_props(kind: &PropfindKind) -> bool { - match kind { - PropfindKind::AllProp { .. } | PropfindKind::PropName => true, - PropfindKind::Prop(requested) => requested_props_may_need_dead_lookup(requested), - } -} - -fn propfind_kind_needs_dead_prop_content(kind: &PropfindKind) -> bool { - !matches!(kind, PropfindKind::PropName) -} - -fn propfind_kind_needs_lockdiscovery(kind: &PropfindKind) -> bool { - match kind { - PropfindKind::AllProp { .. } => true, - PropfindKind::PropName => false, - PropfindKind::Prop(requested) => requested.iter().any(is_lockdiscovery_prop), - } -} - -fn requested_prop_may_be_dead_property(prop: &RequestedProp) -> bool { - if prop.is_system_namespace() { - return false; - } - match prop.namespace.as_deref() { - Some("DAV:") => false, - Some(_) => true, - None => !is_standard_live_prop_name(&prop.name), - } -} - -fn is_lockdiscovery_prop(prop: &RequestedProp) -> bool { - prop.namespace.as_deref().unwrap_or("DAV:") == "DAV:" && prop.name == "lockdiscovery" -} - -fn standard_prop_element( - prefix: &str, - resource: &PropfindResource, - requested: &RequestedProp, - preload: &PropfindPreload, -) -> Result, HttpResponse> { - if requested.namespace.as_deref().unwrap_or("DAV:") != "DAV:" { - return Ok(None); - } - - let mut element = requested.empty_element(); - match requested.name.as_str() { - "displayname" => { - let display = display_name(&resource.relative); - if !display.is_empty() { - element.children.push(XMLNode::Text(display.to_string())); - } - Ok(Some(element)) - } - "resourcetype" => { - if resource.meta.is_dir() { - element - .children - .push(XMLNode::Element(dav_element("collection"))); - } - Ok(Some(element)) - } - "getcontentlength" => { - if resource.meta.is_dir() { - return Ok(None); - } - element - .children - .push(XMLNode::Text(resource.meta.len().to_string())); - Ok(Some(element)) - } - "getcontenttype" => { - if resource.meta.is_dir() { - return Ok(None); - } - let Some(content_type) = resource - .meta - .content_type() - .filter(|value| !value.is_empty()) - else { - return Ok(None); - }; - element - .children - .push(XMLNode::Text(content_type.to_string())); - Ok(Some(element)) - } - "getlastmodified" => { - let modified = resource.meta.modified().map_err(fs_error_response)?; - element - .children - .push(XMLNode::Text(format_http_date(modified))); - Ok(Some(element)) - } - "creationdate" => { - let created = resource.meta.created().map_err(fs_error_response)?; - element - .children - .push(XMLNode::Text(format_creation_date(created))); - Ok(Some(element)) - } - "getetag" => { - if let Some(etag) = resource.meta.etag() { - element.children.push(XMLNode::Text(format!("\"{etag}\""))); - } - Ok(Some(element)) - } - "supportedlock" => { - let supported = supportedlock_element(); - Ok(Some(supported)) - } - "lockdiscovery" => Ok(Some(lockdiscovery_element( - preload.locks_for(resource), - prefix, - ))), - _ => Ok(None), - } -} - -fn prop_from_xml(prop: &Element, inherited_lang: Option<&str>) -> DavProp { - let mut prop = prop.clone(); - if let Some(lang) = inherited_lang - && !lang.is_empty() - { - prop.attributes - .entry("xml:lang".to_string()) - .or_insert_with(|| lang.to_string()); - } - - DavProp { - name: prop.name.clone(), - prefix: prop.prefix.clone(), - namespace: prop.namespace.clone(), - xml: xml_bytes(&prop).ok(), - } -} - -fn prop_element(prop: &DavProp, requested: Option<&RequestedProp>) -> Element { - let requested_prefix = requested.and_then(|prop| prop.prefix.as_deref()); - let requested_namespace = requested.and_then(|prop| prop.namespace.as_deref()); - let namespace = requested_namespace.or(prop.namespace.as_deref()); - let prefix = requested_prefix - .or(prop.prefix.as_deref()) - .unwrap_or_else(|| default_prefix(namespace)); - let tag = if namespace.is_some() { - format!("{prefix}:{}", prop.name) - } else { - prop.name.clone() - }; - let mut element = Element::new(&tag); - if let Some(namespace) = namespace - && should_declare_namespace(prefix, namespace) - { - element - .attributes - .insert(format!("xmlns:{prefix}"), namespace.to_string()); - } - append_stored_property_data(&mut element, prop); - element -} - -fn dav_prop_key(prop: &DavProp) -> (String, Option) { - (prop.name.clone(), prop.namespace.clone()) -} - -fn append_stored_property_data(element: &mut Element, prop: &DavProp) { - let Some(xml) = &prop.xml else { - return; - }; - if xml.is_empty() { - return; - } - - if let Ok(stored) = parse_webdav_element(xml) - && stored.name == prop.name - && stored.namespace.as_deref() == prop.namespace.as_deref() - { - copy_dead_property_attributes(element, &stored); - element.children.extend(stored.children); - return; - } - - element - .children - .push(XMLNode::Text(String::from_utf8_lossy(xml).into_owned())); -} - -fn copy_dead_property_attributes(target: &mut Element, stored: &Element) { - for (key, value) in &stored.attributes { - if key.starts_with("xmlns") { - continue; - } - let key = if key == "lang" { - "xml:lang" - } else { - key.as_str() - }; - target - .attributes - .entry(key.to_string()) - .or_insert_with(|| value.clone()); - } -} - -fn xml_lang_value(element: &Element) -> Option<&str> { - element - .attributes - .get("xml:lang") - .or_else(|| element.attributes.get("lang")) - .map(String::as_str) -} - -fn is_root_resource(resource: &PropfindResource) -> bool { - // The mount root has no dead-property backing store. PROPFIND may expose - // its live DAV properties, while PROPPATCH rejects "/" explicitly. - resource.relative == "/" -} - -fn standard_prop_name_list(resource: &PropfindResource) -> Vec<&'static str> { - let mut props = vec![ - "displayname", - "resourcetype", - "getlastmodified", - "creationdate", - "getetag", - "lockdiscovery", - "supportedlock", - ]; - if !resource.meta.is_dir() { - props.insert(2, "getcontentlength"); - props.insert(3, "getcontenttype"); - } - props -} - -fn is_standard_live_prop_name(name: &str) -> bool { - matches!( - name, - "displayname" - | "resourcetype" - | "getcontentlength" - | "getcontenttype" - | "getlastmodified" - | "creationdate" - | "getetag" - | "lockdiscovery" - | "supportedlock" - ) -} - -fn default_prefix(namespace: Option<&str>) -> &str { - match namespace { - Some("DAV:") => "D", - Some(_) => "A", - None => "", - } -} - -fn should_declare_namespace(prefix: &str, namespace: &str) -> bool { - namespace != "DAV:" || prefix != "D" -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; - use std::time::SystemTime; - - use actix_web::body::to_bytes; - use actix_web::http::{Method, StatusCode, header}; - use actix_web::test::TestRequest; - use xmltree::Element; - - use super::handle_propfind; - use crate::types::EntityType; - use crate::webdav::dav::{ - DavDirEntry, DavFile, DavFileSystem, DavLock, DavLockError, DavLockSystem, DavMetaData, - DavPath, DavProp, FsError, FsFuture, FsResult, FsStream, LsFuture, OpenOptions, - ReadDirMeta, - }; - - struct PropfindTestFs { - child_count: usize, - metadata_calls: Arc, - get_props_calls: Arc, - } - - struct PropfindTestMeta { - is_dir: bool, - len: u64, - content_type: Option<&'static str>, - property_entity: Option<(EntityType, i64)>, - } - - impl DavMetaData for PropfindTestMeta { - fn len(&self) -> u64 { - self.len - } - - fn modified(&self) -> FsResult { - Ok(SystemTime::UNIX_EPOCH) - } - - fn is_dir(&self) -> bool { - self.is_dir - } - - fn etag(&self) -> Option { - Some(if self.is_dir { - "dir-etag".to_string() - } else { - format!("file-etag-{}", self.len) - }) - } - - fn content_type(&self) -> Option<&str> { - self.content_type - } - - fn created(&self) -> FsResult { - Ok(SystemTime::UNIX_EPOCH) - } - - fn property_entity(&self) -> Option<(EntityType, i64)> { - self.property_entity - } - } - - struct PropfindTestEntry { - name: Vec, - len: u64, - } - - impl DavDirEntry for PropfindTestEntry { - fn name(&self) -> Vec { - self.name.clone() - } - - fn metadata<'a>(&'a self) -> FsFuture<'a, Box> { - Box::pin(async move { - Ok(Box::new(PropfindTestMeta { - is_dir: false, - len: self.len, - content_type: Some("text/plain"), - property_entity: Some(( - EntityType::File, - i64::try_from(self.len).expect("test len should fit i64"), - )), - }) as Box) - }) - } - } - - impl DavFileSystem for PropfindTestFs { - fn open<'a>( - &'a self, - _path: &'a DavPath, - _options: OpenOptions, - ) -> FsFuture<'a, Box> { - Box::pin(async { Err(FsError::GeneralFailure) }) - } - - fn read_dir<'a>( - &'a self, - path: &'a DavPath, - _meta: ReadDirMeta, - ) -> FsFuture<'a, FsStream>> { - Box::pin(async move { - if path.as_str() != "/" { - return Err(FsError::NotFound); - } - - let entries = (0..self.child_count) - .map(|index| { - Ok(Box::new(PropfindTestEntry { - name: format!("file-{index}.txt").into_bytes(), - len: u64::try_from(index + 1).expect("test index should fit u64"), - }) as Box) - }) - .collect::>(); - Ok(Box::pin(futures::stream::iter(entries)) as FsStream>) - }) - } - - fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { - Box::pin(async move { - self.metadata_calls.fetch_add(1, Ordering::SeqCst); - if path.as_str() == "/" { - return Ok(Box::new(PropfindTestMeta { - is_dir: true, - len: 0, - content_type: None, - property_entity: None, - }) as Box); - } - - Ok(Box::new(PropfindTestMeta { - is_dir: false, - len: 1, - content_type: Some("text/plain"), - property_entity: Some((EntityType::File, 1)), - }) as Box) - }) - } - - fn create_dir<'a>(&'a self, _path: &'a DavPath) -> FsFuture<'a, ()> { - Box::pin(async { Err(FsError::GeneralFailure) }) - } - - fn remove_dir<'a>(&'a self, _path: &'a DavPath) -> FsFuture<'a, ()> { - Box::pin(async { Err(FsError::GeneralFailure) }) - } - - fn remove_file<'a>(&'a self, _path: &'a DavPath) -> FsFuture<'a, ()> { - Box::pin(async { Err(FsError::GeneralFailure) }) - } - - fn rename<'a>(&'a self, _from: &'a DavPath, _to: &'a DavPath) -> FsFuture<'a, ()> { - Box::pin(async { Err(FsError::GeneralFailure) }) - } - - fn copy<'a>(&'a self, _from: &'a DavPath, _to: &'a DavPath) -> FsFuture<'a, ()> { - Box::pin(async { Err(FsError::GeneralFailure) }) - } - - fn get_props<'a>( - &'a self, - path: &'a DavPath, - do_content: bool, - ) -> FsFuture<'a, Vec> { - Box::pin(async move { - self.get_props_calls.fetch_add(1, Ordering::SeqCst); - if path.as_str() == "/" { - return Ok(Vec::new()); - } - Ok(vec![DavProp { - name: "color".to_string(), - prefix: Some("A".to_string()), - namespace: Some("urn:aster:test".to_string()), - xml: do_content - .then(|| b"blue".to_vec()), - }]) - }) - } - } - - struct PropfindTestLockSystem { - discover_calls: Arc, - discover_many_calls: Arc, - } - - impl DavLockSystem for PropfindTestLockSystem { - fn lock( - &self, - _path: &DavPath, - _principal: Option<&str>, - _owner: Option<&Element>, - _timeout: Option, - _shared: bool, - _deep: bool, - ) -> LsFuture<'_, Result> { - Box::pin(async { Err(DavLockError::Backend) }) - } - - fn unlock(&self, _path: &DavPath, _token: &str) -> LsFuture<'_, Result<(), ()>> { - Box::pin(async { Ok(()) }) - } - - fn refresh( - &self, - _path: &DavPath, - _token: &str, - _timeout: Option, - ) -> LsFuture<'_, Result> { - Box::pin(async { Err(()) }) - } - - fn check( - &self, - _path: &DavPath, - _principal: Option<&str>, - _ignore_principal: bool, - _deep: bool, - _submitted_tokens: &[String], - ) -> LsFuture<'_, Result<(), DavLock>> { - Box::pin(async { Ok(()) }) - } - - fn discover(&self, _path: &DavPath) -> LsFuture<'_, Vec> { - Box::pin(async move { - self.discover_calls.fetch_add(1, Ordering::SeqCst); - Vec::new() - }) - } - - fn discover_many<'a>( - &'a self, - paths: &'a [DavPath], - ) -> LsFuture<'a, HashMap>> { - Box::pin(async move { - self.discover_many_calls.fetch_add(1, Ordering::SeqCst); - paths - .iter() - .map(|path| (path.clone(), Vec::new())) - .collect::>() - }) - } - - fn conflicting_locks(&self, _path: &DavPath, _deep: bool) -> LsFuture<'_, Vec> { - Box::pin(async { Vec::new() }) - } - - fn delete(&self, _path: &DavPath) -> LsFuture<'_, Result<(), ()>> { - Box::pin(async { Ok(()) }) - } - } - - async fn propfind_depth_one(body: &'static str) -> (String, usize, usize, usize, usize) { - const CHILD_COUNT: usize = 24; - - let metadata_calls = Arc::new(AtomicUsize::new(0)); - let get_props_calls = Arc::new(AtomicUsize::new(0)); - let discover_calls = Arc::new(AtomicUsize::new(0)); - let discover_many_calls = Arc::new(AtomicUsize::new(0)); - let fs = PropfindTestFs { - child_count: CHILD_COUNT, - metadata_calls: metadata_calls.clone(), - get_props_calls: get_props_calls.clone(), - }; - let lock_system = PropfindTestLockSystem { - discover_calls: discover_calls.clone(), - discover_many_calls: discover_many_calls.clone(), - }; - let req = TestRequest::default() - .method(Method::from_bytes(b"PROPFIND").expect("valid method")) - .uri("/webdav/") - .insert_header((header::HeaderName::from_static("depth"), "1")) - .to_http_request(); - - let response = handle_propfind(&req, &fs, &lock_system, "/webdav", body.as_bytes()).await; - assert_eq!(response.status(), StatusCode::MULTI_STATUS); - let body = to_bytes(response.into_body()) - .await - .expect("PROPFIND response body should be readable"); - ( - String::from_utf8(body.to_vec()).expect("PROPFIND body should be utf-8"), - metadata_calls.load(Ordering::SeqCst), - get_props_calls.load(Ordering::SeqCst), - discover_calls.load(Ordering::SeqCst), - discover_many_calls.load(Ordering::SeqCst), - ) - } - - #[actix_web::test] - async fn propfind_depth_one_live_props_do_not_load_dead_properties() { - let body = r#" - - - - - - - - - - -"#; - - let (xml, metadata_calls, calls, discover_calls, discover_many_calls) = - propfind_depth_one(body).await; - - assert_eq!( - metadata_calls, 1, - "Depth: 1 PROPFIND should reuse root metadata instead of loading it twice: {xml}" - ); - assert_eq!( - calls, 0, - "live-property-only Depth: 1 PROPFIND should not load dead properties: {xml}" - ); - assert_eq!( - xml.matches("").count(), - 25, - "large-directory fixture should include parent plus all children: {xml}" - ); - assert!( - xml.contains("file-23.txt") && xml.contains("getlastmodified"), - "live property response should still include child resources and requested live props: {xml}" - ); - assert!( - xml.contains("text/plain"), - "file live property response should include stored content type: {xml}" - ); - assert_eq!(discover_calls, 0, "live props should not discover locks"); - assert_eq!( - discover_many_calls, 0, - "live props should not batch-discover locks" - ); - } - - #[actix_web::test] - async fn propfind_depth_one_custom_prop_still_loads_dead_properties() { - let body = r#" - - - - - -"#; - - let (xml, _, calls, _, _) = propfind_depth_one(body).await; - - assert_eq!( - calls, 24, - "custom prop lookup should still load child dead properties for Depth: 1: {xml}" - ); - assert!( - xml.contains("blue"), - "custom dead property should still be returned: {xml}" - ); - } - - #[actix_web::test] - async fn propfind_depth_one_allprop_still_loads_dead_properties() { - let (xml, _, calls, _, discover_many_calls) = propfind_depth_one("").await; - - assert_eq!( - calls, 24, - "allprop must continue loading child dead properties for Depth: 1: {xml}" - ); - assert!( - xml.contains("blue"), - "allprop should include custom dead properties: {xml}" - ); - assert_eq!( - discover_many_calls, 1, - "allprop should batch-load lockdiscovery once" - ); - } - - #[actix_web::test] - async fn propfind_depth_one_propname_does_not_load_lock_values() { - let body = r#" - - -"#; - - let (xml, _, _, discover_calls, discover_many_calls) = propfind_depth_one(body).await; - - assert!( - xml.contains(""), - "propname should list lockdiscovery as a live property name: {xml}" - ); - assert_eq!( - discover_calls, 0, - "propname must not load per-resource lock values" - ); - assert_eq!( - discover_many_calls, 0, - "propname must not batch-load lock values" - ); - } - - #[actix_web::test] - async fn propfind_depth_one_lockdiscovery_uses_batch_discovery() { - let body = r#" - - - - -"#; - - let (xml, _, _, discover_calls, discover_many_calls) = propfind_depth_one(body).await; - - assert!( - xml.contains("lockdiscovery"), - "explicit lockdiscovery request should return lockdiscovery elements: {xml}" - ); - assert_eq!( - discover_calls, 0, - "lockdiscovery should not fall back to per-resource discover calls" - ); - assert_eq!( - discover_many_calls, 1, - "Depth: 1 lockdiscovery should use one batch discovery" - ); - } -} diff --git a/src/webdav/protocol.rs b/src/webdav/protocol.rs deleted file mode 100644 index b030fb33e..000000000 --- a/src/webdav/protocol.rs +++ /dev/null @@ -1,1115 +0,0 @@ -//! WebDAV protocol parsing helpers. - -use std::time::SystemTime; - -use actix_web::HttpResponse; -use actix_web::http::header; - -use crate::webdav::dav::{DavFileSystem, DavLockSystem, DavPath, FsError}; -use crate::webdav::{decode_relative_path, responses}; -use aster_forge_utils::http_validators; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Depth { - Zero, - One, - Infinity, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct IfHeader { - groups: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HttpEtagPrecondition { - Proceed, - NotModified, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct IfResourceGroup { - tagged_path: Option, - lists: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct IfStateList { - conditions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum IfStateCondition { - Token { value: String, negated: bool }, - Etag { value: String, negated: bool }, -} - -impl Depth { - pub(crate) fn is_infinity(self) -> bool { - matches!(self, Self::Infinity) - } -} - -pub(crate) fn parse_propfind_depth(headers: &header::HeaderMap) -> Result { - match parse_depth_header(headers)? { - Some(Depth::Zero) => Ok(Depth::Zero), - Some(Depth::One) => Ok(Depth::One), - Some(Depth::Infinity) | None => Ok(Depth::Infinity), - } -} - -pub(crate) fn parse_copy_depth(headers: &header::HeaderMap) -> Result { - match parse_depth_header(headers)? { - Some(Depth::Zero) => Ok(Depth::Zero), - Some(Depth::Infinity) | None => Ok(Depth::Infinity), - Some(Depth::One) => Err(responses::bad_request()), - } -} - -pub(crate) fn parse_move_depth(headers: &header::HeaderMap) -> Result { - Ok(parse_depth_header(headers)?.unwrap_or(Depth::Infinity)) -} - -pub(crate) fn parse_delete_depth(headers: &header::HeaderMap) -> Result { - Ok(parse_depth_header(headers)?.unwrap_or(Depth::Infinity)) -} - -pub(crate) fn parse_lock_depth(headers: &header::HeaderMap) -> Result { - match parse_depth_header(headers)? { - None | Some(Depth::Infinity) => Ok(Depth::Infinity), - Some(Depth::Zero) => Ok(Depth::Zero), - Some(Depth::One) => Err(responses::bad_request()), - } -} - -fn parse_depth_header(headers: &header::HeaderMap) -> Result, HttpResponse> { - let Some(value) = headers.get("Depth") else { - return Ok(None); - }; - let value = value.to_str().map_err(|_| responses::bad_request())?; - - match value { - value if value.eq_ignore_ascii_case("0") => Ok(Some(Depth::Zero)), - value if value.eq_ignore_ascii_case("1") => Ok(Some(Depth::One)), - value if value.eq_ignore_ascii_case("infinity") => Ok(Some(Depth::Infinity)), - _ => Err(responses::bad_request()), - } -} - -pub(crate) fn parse_overwrite(headers: &header::HeaderMap) -> Result { - let Some(value) = headers.get("Overwrite") else { - return Ok(true); - }; - let value = value - .to_str() - .map_err(|_| invalid_overwrite_header())? - .trim(); - if value.eq_ignore_ascii_case("T") { - Ok(true) - } else if value.eq_ignore_ascii_case("F") { - Ok(false) - } else { - Err(invalid_overwrite_header()) - } -} - -pub(crate) fn destination_relative_path( - headers: &header::HeaderMap, - prefix: &str, - request_scheme: &str, - request_host: &str, -) -> Result { - let raw = headers - .get("Destination") - .ok_or_else(|| responses::bad_request_text("Missing Destination header"))? - .to_str() - .map_err(|_| invalid_destination_header())? - .trim(); - let uri: http::Uri = raw.parse().map_err(|_| invalid_destination_header())?; - match (uri.scheme_str(), uri.authority()) { - (Some(scheme), Some(authority)) => { - if !scheme.eq_ignore_ascii_case(request_scheme) - || !authority.as_str().eq_ignore_ascii_case(request_host) - { - return Err(responses::bad_request_text( - "Destination must stay on this WebDAV server", - )); - } - } - (None, None) => { - if !raw.starts_with('/') { - return Err(invalid_destination_header()); - } - } - _ => return Err(invalid_destination_header()), - }; - let path = uri.path().to_string(); - let relative = path - .strip_prefix(prefix) - .filter(|_| { - path == prefix - || path - .as_bytes() - .get(prefix.len()) - .is_some_and(|byte| *byte == b'/') - }) - .ok_or_else(|| responses::bad_request_text("Destination must stay under WebDAV prefix"))?; - decode_relative_path(relative).map(|(_, relative)| relative) -} - -pub(crate) fn submitted_lock_tokens_for_path( - headers: &header::HeaderMap, - request_path: &str, - request_scheme: &str, - request_host: &str, -) -> Vec { - let Some(if_header) = parsed_if_header_for_token_submission(headers) else { - return Vec::new(); - }; - submitted_lock_tokens_from_if_header(&if_header, |tagged_path| { - if_tag_matches_path(tagged_path, request_path, request_scheme, request_host) - }) -} - -fn parsed_if_header_for_token_submission(headers: &header::HeaderMap) -> Option { - parse_if_header(headers).unwrap_or_default() -} - -fn submitted_lock_tokens_from_if_header(if_header: &IfHeader, mut tag_matches: F) -> Vec -where - F: FnMut(&str) -> bool, -{ - let mut tokens = Vec::new(); - - for group in &if_header.groups { - match group.tagged_path.as_deref() { - None => {} - Some(tagged_path) if tag_matches(tagged_path) => {} - Some(_) => continue, - } - for list in &group.lists { - for condition in &list.conditions { - if let IfStateCondition::Token { value, .. } = condition { - tokens.push(value.clone()); - } - } - } - } - - tokens.sort(); - tokens.dedup(); - tokens -} - -pub(crate) async fn ensure_if_header( - headers: &header::HeaderMap, - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - request_path: &DavPath, - prefix: &str, - request_scheme: &str, - request_host: &str, -) -> Result<(), HttpResponse> { - let Some(if_header) = parse_if_header(headers)? else { - return Ok(()); - }; - - if evaluate_if_header( - &if_header, - dav_fs, - lock_system, - request_path, - prefix, - request_scheme, - request_host, - ) - .await? - { - Ok(()) - } else { - Err(responses::precondition_failed()) - } -} - -pub(crate) fn evaluate_http_etag_preconditions( - headers: &header::HeaderMap, - resource_exists: bool, - current_etag: Option<&str>, - safe_method: bool, -) -> Result { - if let Some(value) = headers.get(header::IF_MATCH) { - let raw = value.to_str().map_err(|_| invalid_if_match_header())?; - if !http_validators::if_match_header_matches(raw, resource_exists, current_etag) - .map_err(|_| invalid_if_match_header())? - { - return Err(responses::precondition_failed()); - } - } - - if let Some(value) = headers.get(header::IF_NONE_MATCH) { - let raw = value.to_str().map_err(|_| invalid_if_none_match_header())?; - if http_validators::if_none_match_header_matches(raw, resource_exists, current_etag) - .map_err(|_| invalid_if_none_match_header())? - { - return if safe_method { - Ok(HttpEtagPrecondition::NotModified) - } else { - Err(responses::precondition_failed()) - }; - } - } - - Ok(HttpEtagPrecondition::Proceed) -} - -pub(crate) fn evaluate_http_download_preconditions( - headers: &header::HeaderMap, - current_etag: Option<&str>, - last_modified: Option, -) -> Result { - let has_if_match = headers.contains_key(header::IF_MATCH); - if let Some(value) = headers.get(header::IF_MATCH) { - let raw = value.to_str().map_err(|_| invalid_if_match_header())?; - if !http_validators::if_match_header_matches(raw, true, current_etag) - .map_err(|_| invalid_if_match_header())? - { - return Err(responses::precondition_failed()); - } - } - - if !has_if_match - && let (Some(value), Some(last_modified)) = - (headers.get(header::IF_UNMODIFIED_SINCE), last_modified) - { - let since = parse_http_date_header(value, invalid_if_unmodified_since_header)?; - if http_validators::http_date_epoch_seconds(last_modified) - > http_validators::http_date_epoch_seconds(since) - { - return Err(responses::precondition_failed()); - } - } - - let has_if_none_match = headers.contains_key(header::IF_NONE_MATCH); - if let Some(value) = headers.get(header::IF_NONE_MATCH) { - let raw = value.to_str().map_err(|_| invalid_if_none_match_header())?; - if http_validators::if_none_match_header_matches(raw, true, current_etag) - .map_err(|_| invalid_if_none_match_header())? - { - return Ok(HttpEtagPrecondition::NotModified); - } - } - - if !has_if_none_match - && let (Some(value), Some(last_modified)) = - (headers.get(header::IF_MODIFIED_SINCE), last_modified) - { - let since = parse_http_date_header(value, invalid_if_modified_since_header)?; - if http_validators::http_date_epoch_seconds(last_modified) - <= http_validators::http_date_epoch_seconds(since) - { - return Ok(HttpEtagPrecondition::NotModified); - } - } - - Ok(HttpEtagPrecondition::Proceed) -} - -async fn evaluate_if_header( - if_header: &IfHeader, - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - request_path: &DavPath, - prefix: &str, - request_scheme: &str, - request_host: &str, -) -> Result { - for group in &if_header.groups { - let path = match group.tagged_path.as_deref() { - Some(tagged_path) => { - tagged_dav_path(prefix, tagged_path, request_scheme, request_host)? - } - None => Some(request_path.clone()), - }; - if evaluate_if_resource_group(group, dav_fs, lock_system, path.as_ref()).await? { - return Ok(true); - } - } - - Ok(false) -} - -async fn evaluate_if_resource_group( - group: &IfResourceGroup, - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - path: Option<&DavPath>, -) -> Result { - for list in &group.lists { - if evaluate_if_state_list(list, dav_fs, lock_system, path).await? { - return Ok(true); - } - } - Ok(false) -} - -async fn evaluate_if_state_list( - list: &IfStateList, - dav_fs: &dyn DavFileSystem, - lock_system: &dyn DavLockSystem, - path: Option<&DavPath>, -) -> Result { - let current_etag = match path { - Some(path) => match dav_fs.metadata(path).await { - Ok(meta) => meta.etag(), - Err(FsError::NotFound) => None, - Err(err) => return Err(crate::webdav::fs_error_response(err)), - }, - None => None, - }; - let lock_tokens = match path { - Some(path) => lock_system - .discover(path) - .await - .into_iter() - .map(|lock| lock.token) - .collect::>(), - None => Vec::new(), - }; - - for condition in &list.conditions { - let matched = match condition { - IfStateCondition::Token { value, negated } => { - lock_tokens.iter().any(|token| token == value) ^ *negated - } - IfStateCondition::Etag { value, negated } => { - current_etag.as_deref().is_some_and(|etag| { - http_validators::if_none_match_header_matches(value, true, Some(etag)) - .unwrap_or(false) - }) ^ *negated - } - }; - if !matched { - return Ok(false); - } - } - - Ok(true) -} - -fn parse_if_header(headers: &header::HeaderMap) -> Result, HttpResponse> { - let Some(value) = headers.get("If") else { - return Ok(None); - }; - let raw = value.to_str().map_err(|_| invalid_if_header())?; - - let mut parser = IfHeaderParser::new(raw); - parser.parse().map(Some) -} - -fn normalize_lock_token(value: &str) -> String { - value - .trim() - .trim_matches(|c| c == '<' || c == '>') - .to_string() -} - -fn tagged_dav_path( - prefix: &str, - tagged_path: &str, - request_scheme: &str, - request_host: &str, -) -> Result, HttpResponse> { - let uri: http::Uri = tagged_path.parse().map_err(|_| invalid_if_header())?; - let path = match (uri.scheme_str(), uri.authority()) { - (Some(scheme), Some(authority)) => { - if !scheme.eq_ignore_ascii_case(request_scheme) - || !authority.as_str().eq_ignore_ascii_case(request_host) - { - return Ok(None); - } - uri.path().to_string() - } - (None, None) => uri.path().to_string(), - _ => return Err(invalid_if_header()), - }; - if !path.starts_with('/') { - return Err(invalid_if_header()); - } - - let Some(relative) = path.strip_prefix(prefix).filter(|_| { - path == prefix - || path - .as_bytes() - .get(prefix.len()) - .is_some_and(|byte| *byte == b'/') - }) else { - return Ok(None); - }; - let (path, _) = decode_relative_path(relative)?; - Ok(Some(path)) -} - -fn parse_http_date_header( - value: &header::HeaderValue, - invalid_response: fn() -> HttpResponse, -) -> Result { - let raw = value.to_str().map_err(|_| invalid_response())?; - http_validators::parse_http_date(raw).map_err(|_| invalid_response()) -} - -fn invalid_destination_header() -> HttpResponse { - responses::bad_request_text("Invalid Destination header") -} - -fn invalid_if_header() -> HttpResponse { - responses::bad_request_text("Invalid If header") -} - -fn invalid_if_match_header() -> HttpResponse { - responses::bad_request_text("Invalid If-Match header") -} - -fn invalid_if_none_match_header() -> HttpResponse { - responses::bad_request_text("Invalid If-None-Match header") -} - -fn invalid_if_modified_since_header() -> HttpResponse { - responses::bad_request_text("Invalid If-Modified-Since header") -} - -fn invalid_if_unmodified_since_header() -> HttpResponse { - responses::bad_request_text("Invalid If-Unmodified-Since header") -} - -fn invalid_overwrite_header() -> HttpResponse { - responses::bad_request_text("Invalid Overwrite header") -} - -struct IfHeaderParser<'a> { - input: &'a str, - pos: usize, -} - -impl<'a> IfHeaderParser<'a> { - fn new(input: &'a str) -> Self { - Self { input, pos: 0 } - } - - fn parse(&mut self) -> Result { - self.skip_lws(); - if self.is_eof() { - return Err(invalid_if_header()); - } - - let first_is_tagged = self.peek_char() == Some('<'); - let mut groups = Vec::new(); - if first_is_tagged { - while !self.is_eof() { - let tagged_path = self.parse_angle_value()?; - let mut lists = Vec::new(); - loop { - self.skip_lws(); - if self.peek_char() != Some('(') { - break; - } - lists.push(self.parse_state_list()?); - } - if lists.is_empty() { - return Err(invalid_if_header()); - } - groups.push(IfResourceGroup { - tagged_path: Some(tagged_path), - lists, - }); - self.skip_lws(); - if self.is_eof() { - break; - } - if self.peek_char() != Some('<') { - return Err(invalid_if_header()); - } - } - } else { - let mut lists = Vec::new(); - while !self.is_eof() { - lists.push(self.parse_state_list()?); - self.skip_lws(); - if self.peek_char() == Some('<') { - return Err(invalid_if_header()); - } - } - groups.push(IfResourceGroup { - tagged_path: None, - lists, - }); - } - - Ok(IfHeader { groups }) - } - - fn parse_state_list(&mut self) -> Result { - self.expect_char('(')?; - let mut conditions = Vec::new(); - loop { - self.skip_lws(); - if self.peek_char() == Some(')') { - self.pos += 1; - break; - } - if self.is_eof() { - return Err(invalid_if_header()); - } - - let negated = self.consume_not(); - self.skip_lws(); - let condition = match self.peek_char() { - Some('<') => IfStateCondition::Token { - value: normalize_lock_token(&self.parse_angle_value()?), - negated, - }, - Some('[') => IfStateCondition::Etag { - value: self.parse_bracket_value()?, - negated, - }, - _ => return Err(invalid_if_header()), - }; - conditions.push(condition); - } - - if conditions.is_empty() { - return Err(invalid_if_header()); - } - Ok(IfStateList { conditions }) - } - - fn parse_angle_value(&mut self) -> Result { - self.expect_char('<')?; - let start = self.pos; - while let Some(ch) = self.peek_char() { - if ch == '>' { - let value = self.input[start..self.pos].trim(); - self.pos += 1; - if value.is_empty() { - return Err(invalid_if_header()); - } - return Ok(value.to_string()); - } - self.pos += ch.len_utf8(); - } - Err(invalid_if_header()) - } - - fn parse_bracket_value(&mut self) -> Result { - self.expect_char('[')?; - let start = self.pos; - while let Some(ch) = self.peek_char() { - if ch == ']' { - let value = self.input[start..self.pos].trim(); - self.pos += 1; - if value.is_empty() { - return Err(invalid_if_header()); - } - return Ok(value.to_string()); - } - self.pos += ch.len_utf8(); - } - Err(invalid_if_header()) - } - - fn consume_not(&mut self) -> bool { - let rest = &self.input[self.pos..]; - let Some(candidate) = rest.get(..3) else { - return false; - }; - if !candidate.eq_ignore_ascii_case("not") { - return false; - } - let after_not = &rest[3..]; - if after_not - .chars() - .next() - .is_some_and(|ch| !ch.is_ascii_whitespace() && ch != '<' && ch != '[') - { - return false; - } - self.pos += 3; - true - } - - fn expect_char(&mut self, expected: char) -> Result<(), HttpResponse> { - if self.peek_char() == Some(expected) { - self.pos += expected.len_utf8(); - Ok(()) - } else { - Err(invalid_if_header()) - } - } - - fn skip_lws(&mut self) { - while self - .peek_char() - .is_some_and(|ch| ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') - { - self.pos += 1; - } - } - - fn peek_char(&self) -> Option { - self.input[self.pos..].chars().next() - } - - fn is_eof(&self) -> bool { - self.pos >= self.input.len() - } -} - -fn if_tag_matches_path( - tagged_path: &str, - request_path: &str, - request_scheme: &str, - request_host: &str, -) -> bool { - if path_equivalent(tagged_path, request_path) { - return true; - } - let parsed = tagged_path.parse::(); - let Ok(uri) = parsed else { - return false; - }; - match (uri.scheme_str(), uri.authority()) { - (Some(scheme), Some(authority)) => { - scheme.eq_ignore_ascii_case(request_scheme) - && authority.as_str().eq_ignore_ascii_case(request_host) - && path_equivalent(uri.path(), request_path) - } - (None, None) => path_equivalent(uri.path(), request_path), - _ => false, - } -} - -fn path_equivalent(left: &str, right: &str) -> bool { - if left == right { - return true; - } - let left_decoded = urlencoding::decode(left).ok(); - let right_decoded = urlencoding::decode(right).ok(); - match (left_decoded.as_deref(), right_decoded.as_deref()) { - (Some(left), Some(right)) => left == right, - (Some(left), None) => left == right, - (None, Some(right)) => left == right, - (None, None) => false, - } -} - -#[cfg(test)] -mod tests { - use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue}; - - use super::{ - Depth, IfHeader, IfStateCondition, destination_relative_path, parse_copy_depth, - parse_delete_depth, parse_if_header, parse_lock_depth, parse_move_depth, - parse_propfind_depth, submitted_lock_tokens_for_path, - }; - - fn headers(name: &'static str, value: &'static str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_bytes(name.as_bytes()).expect("test header name should be valid"), - HeaderValue::from_static(value), - ); - headers - } - - async fn response_body(response: actix_web::HttpResponse) -> String { - let bytes = actix_web::body::to_bytes(response.into_body()) - .await - .expect("response body should be readable"); - String::from_utf8(bytes.to_vec()).expect("response body should be utf-8") - } - - #[test] - fn propfind_missing_depth_defaults_to_infinity() { - let headers = HeaderMap::new(); - - assert_eq!(parse_propfind_depth(&headers).unwrap(), Depth::Infinity); - } - - #[test] - fn propfind_accepts_zero_one_and_infinity_depth() { - assert_eq!( - parse_propfind_depth(&headers("Depth", "0")).unwrap(), - Depth::Zero - ); - assert_eq!( - parse_propfind_depth(&headers("Depth", "1")).unwrap(), - Depth::One - ); - assert_eq!( - parse_propfind_depth(&headers("Depth", "infinity")).unwrap(), - Depth::Infinity - ); - } - - #[test] - fn depth_header_values_are_case_insensitive_but_not_whitespace_tolerant() { - assert_eq!( - parse_propfind_depth(&headers("Depth", "Infinity")).unwrap(), - Depth::Infinity - ); - assert!(parse_propfind_depth(&headers("Depth", "")).is_err()); - assert!(parse_propfind_depth(&headers("Depth", " infinity ")).is_err()); - } - - #[test] - fn copy_defaults_to_infinity_and_parses_legal_depths() { - assert_eq!( - parse_copy_depth(&HeaderMap::new()).unwrap(), - Depth::Infinity - ); - assert_eq!( - parse_copy_depth(&headers("Depth", "0")).unwrap(), - Depth::Zero - ); - assert_eq!( - parse_copy_depth(&headers("Depth", "infinity")).unwrap(), - Depth::Infinity - ); - } - - #[test] - fn copy_rejects_depth_one_and_invalid_depth_values() { - assert!(parse_copy_depth(&headers("Depth", "1")).is_err()); - assert!(parse_copy_depth(&headers("Depth", "invalid")).is_err()); - } - - #[test] - fn lock_depth_accepts_zero_infinity_and_missing_but_rejects_one() { - assert_eq!( - parse_lock_depth(&HeaderMap::new()).unwrap(), - Depth::Infinity - ); - assert_eq!( - parse_lock_depth(&headers("Depth", "0")).unwrap(), - Depth::Zero - ); - assert_eq!( - parse_lock_depth(&headers("Depth", "infinity")).unwrap(), - Depth::Infinity - ); - assert!(parse_lock_depth(&headers("Depth", "1")).is_err()); - } - - #[test] - fn depth_header_present_but_not_utf8_is_bad_request() { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("depth"), - HeaderValue::from_bytes(&[0xff]).expect("test header value should be constructible"), - ); - - assert!(parse_propfind_depth(&headers).is_err()); - } - - #[test] - fn move_and_delete_parse_legal_depths_before_resource_type_is_known() { - assert_eq!( - parse_move_depth(&headers("Depth", "0")).unwrap(), - Depth::Zero - ); - assert_eq!( - parse_delete_depth(&headers("Depth", "1")).unwrap(), - Depth::One - ); - assert!(parse_move_depth(&headers("Depth", "invalid")).is_err()); - assert!(parse_delete_depth(&headers("Depth", "invalid")).is_err()); - } - - #[test] - fn destination_accepts_relative_and_matching_absolute_urls() { - let relative = destination_relative_path( - &headers("Destination", "/webdav/folder/file%20name.txt"), - "/webdav", - "https", - "dav.example", - ) - .expect("relative destination under prefix should parse"); - assert_eq!(relative, "/folder/file name.txt"); - - let absolute = destination_relative_path( - &headers( - "Destination", - "HTTPS://DAV.EXAMPLE/webdav/folder/file%20name.txt", - ), - "/webdav", - "https", - "dav.example", - ) - .expect("matching absolute destination should parse case-insensitively"); - assert_eq!(absolute, "/folder/file name.txt"); - } - - #[actix_web::test] - async fn destination_rejects_missing_malformed_or_relative_reference_values() { - let response = - destination_relative_path(&HeaderMap::new(), "/webdav", "https", "dav.example") - .expect_err("missing Destination should be rejected"); - assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST); - assert_eq!(response_body(response).await, "Missing Destination header"); - - let response = destination_relative_path( - &headers("Destination", "not-an-absolute-path"), - "/webdav", - "https", - "dav.example", - ) - .expect_err("relative-reference Destination should be rejected"); - assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST); - assert_eq!(response_body(response).await, "Invalid Destination header"); - - let mut invalid_utf8 = HeaderMap::new(); - invalid_utf8.insert( - HeaderName::from_static("destination"), - HeaderValue::from_bytes(&[0xff]).expect("test header value should be constructible"), - ); - let response = destination_relative_path(&invalid_utf8, "/webdav", "https", "dav.example") - .expect_err("non-utf8 Destination should be rejected"); - assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST); - assert_eq!(response_body(response).await, "Invalid Destination header"); - } - - #[actix_web::test] - async fn destination_rejects_cross_origin_and_out_of_prefix_targets() { - for destination in [ - "http://dav.example/webdav/file.txt", - "https://other.example/webdav/file.txt", - ] { - let response = destination_relative_path( - &headers("Destination", destination), - "/webdav", - "https", - "dav.example", - ) - .expect_err("cross-origin Destination should be rejected"); - assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST); - assert_eq!( - response_body(response).await, - "Destination must stay on this WebDAV server" - ); - } - - for destination in ["/webdavish/file.txt", "/outside/file.txt"] { - let response = destination_relative_path( - &headers("Destination", destination), - "/webdav", - "https", - "dav.example", - ) - .expect_err("Destination outside prefix should be rejected"); - assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST); - assert_eq!( - response_body(response).await, - "Destination must stay under WebDAV prefix" - ); - } - } - - #[test] - fn submitted_tokens_for_path_ignore_lock_token_header() { - let mut headers = headers("If", "()"); - headers.insert( - HeaderName::from_static("lock-token"), - HeaderValue::from_static(""), - ); - - assert_eq!( - submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost"), - ["urn:uuid:two".to_string()] - ); - } - - #[test] - fn submitted_tokens_for_path_ignore_other_tagged_resources() { - let mut headers = headers( - "If", - r#" () () ()"#, - ); - headers.insert( - HeaderName::from_static("lock-token"), - HeaderValue::from_static(""), - ); - - assert_eq!( - submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost"), - [ - "urn:uuid:current".to_string(), - "urn:uuid:untagged".to_string() - ] - ); - } - - #[test] - fn submitted_tokens_for_path_honor_absolute_tag_origin() { - let headers = headers( - "If", - r#" () ()"#, - ); - - assert_eq!( - submitted_lock_tokens_for_path( - &headers, - "/webdav/current.txt", - "http", - "localhost:8080" - ), - ["urn:uuid:current".to_string()] - ); - } - - #[test] - fn submitted_tokens_for_path_counts_negated_tokens_as_submitted() { - let headers = headers( - "If", - r#" () (Not )"#, - ); - - assert_eq!( - submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost"), - ["urn:uuid:current".to_string(), "urn:uuid:other".to_string()] - ); - } - - #[test] - fn submitted_tokens_for_path_deduplicates_tokens_across_conditions() { - let headers = headers( - "If", - r#"() () (Not )"#, - ); - - assert_eq!( - submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost"), - ["urn:uuid:current".to_string()] - ); - } - - #[test] - fn submitted_tokens_for_path_ignore_invalid_if_header() { - let headers = headers("If", r#" ("#); - - assert!( - submitted_lock_tokens_for_path(&headers, "/webdav/current.txt", "http", "localhost") - .is_empty() - ); - } - - #[test] - fn submitted_tokens_for_path_match_percent_encoded_tags() { - let headers = headers("If", r#" ()"#); - - assert_eq!( - submitted_lock_tokens_for_path( - &headers, - "/webdav/current file.txt", - "http", - "localhost", - ), - ["urn:uuid:current".to_string()] - ); - } - - #[test] - fn if_header_parser_preserves_not_and_etag_conditions() { - let headers = headers( - "If", - r#"( ["etag-one"]) (Not [W/"etag-two"])"#, - ); - let parsed = parse_if_header(&headers) - .expect("If header should parse") - .expect("If header should exist"); - - assert_eq!(parsed.groups.len(), 1); - assert_eq!(parsed.groups[0].lists.len(), 2); - assert_eq!( - parsed.groups[0].lists[0].conditions, - [ - IfStateCondition::Token { - value: "urn:uuid:one".to_string(), - negated: false, - }, - IfStateCondition::Etag { - value: "\"etag-one\"".to_string(), - negated: false, - }, - ] - ); - assert_eq!( - parsed.groups[0].lists[1].conditions, - [ - IfStateCondition::Token { - value: "urn:uuid:two".to_string(), - negated: true, - }, - IfStateCondition::Etag { - value: "W/\"etag-two\"".to_string(), - negated: false, - }, - ] - ); - } - - #[test] - fn if_header_parser_accepts_case_insensitive_not_keyword() { - let headers = headers("If", r#"(nOt )"#); - let parsed = parse_if_header(&headers) - .expect("If header should parse") - .expect("If header should exist"); - - assert_eq!( - parsed.groups[0].lists[0].conditions, - [IfStateCondition::Token { - value: "urn:uuid:one".to_string(), - negated: true, - }] - ); - } - - #[test] - fn if_header_parser_does_not_consume_not_prefix_without_separator() { - let headers = headers("If", r#"(Notified )"#); - - assert!(parse_if_header(&headers).is_err()); - } - - #[test] - fn if_header_parser_rejects_mixed_tagged_and_untagged_lists() { - let headers = headers( - "If", - r#"() ()"#, - ); - - assert!(parse_if_header(&headers).is_err()); - } - - #[test] - fn if_header_parser_rejects_empty_lists() { - let headers = headers("If", "()"); - - assert!(parse_if_header(&headers).is_err()); - } - - #[test] - fn if_header_parser_groups_tagged_lists_by_resource() { - let parsed = parse_if_header(&headers( - "If", - r#" () () (Not )"#, - )) - .expect("If header should parse") - .expect("If header should exist"); - - let IfHeader { groups } = parsed; - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].tagged_path.as_deref(), Some("/webdav/a.txt")); - assert_eq!(groups[0].lists.len(), 2); - assert_eq!(groups[1].tagged_path.as_deref(), Some("/webdav/b.txt")); - assert_eq!(groups[1].lists.len(), 1); - } -} diff --git a/src/webdav/resources/mod.rs b/src/webdav/resources/mod.rs deleted file mode 100644 index 9d4704ab1..000000000 --- a/src/webdav/resources/mod.rs +++ /dev/null @@ -1,1052 +0,0 @@ -//! WebDAV resource mutation handlers: MKCOL, DELETE, COPY, MOVE. - -use std::collections::HashMap; - -use actix_web::http::{StatusCode, header}; -use actix_web::{HttpRequest, HttpResponse, web}; -use futures::{StreamExt, pin_mut}; -use xmltree::XMLNode; - -use crate::webdav::dav::{DavFileSystem, DavLockSystem, DavPath, FsError, ReadDirMeta}; -use crate::webdav::protocol::{self, Depth}; -use crate::webdav::{ - child_relative_path, dav_element, decoded_path_string, ensure_parent_unlocked, - ensure_system_file_name_allowed, ensure_unlocked, fs, fs_error_response, href_for_dav_path, - href_for_relative, lock_token_submitted_element, multi_status, parent_relative_path, - request_origin, request_path, responses, status_element, system_file, text_element, - xml_response, -}; - -#[derive(Clone)] -struct MultiStatusFailure { - path: DavPath, - status: StatusCode, - lock_path: Option, -} - -struct DavChild { - path: DavPath, - relative: String, - is_dir: bool, -} - -struct PartialMutationOutcome { - failures: Vec, - destination_exists: bool, -} - -struct PartialMutationContext<'a> { - dav_fs: &'a fs::AsterDavFs, - lock_system: &'a dyn DavLockSystem, - req: &'a HttpRequest, - prefix: &'a str, - is_move: bool, -} - -#[derive(Clone)] -struct PartialMutationNode { - source: DavPath, - source_relative: String, - destination: DavPath, - destination_relative: String, -} - -#[derive(Clone)] -struct DestinationMutationNode { - path: DavPath, - relative: String, -} - -enum PartialMutationWork { - VisitDirectory(PartialMutationNode), - ProcessFile(PartialMutationNode), - FinalizeDirectory(PartialMutationNode), - RemoveDestinationFile(DestinationMutationNode), - VisitDestinationDirectory(DestinationMutationNode), - FinalizeDestinationDirectory(DestinationMutationNode), -} - -pub(crate) async fn handle_mkcol( - req: &HttpRequest, - dav_fs: &fs::AsterDavFs, - lock_system: &dyn DavLockSystem, - prefix: &str, - system_file_policy: &system_file::SystemFileBlockPolicy, - payload: &mut web::Payload, -) -> HttpResponse { - if let Err(resp) = ensure_empty_body(payload).await { - return resp; - } - - let (path, relative) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - if relative == "/" { - return responses::empty(StatusCode::METHOD_NOT_ALLOWED); - } - if let Err(resp) = ensure_system_file_name_allowed(system_file_policy, &relative) { - return resp; - } - - if let Err(resp) = ensure_parent_exists(dav_fs, &relative).await { - return resp; - } - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if let Err(resp) = ensure_unlocked( - lock_system, - &path, - false, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if let Err(resp) = ensure_parent_unlocked( - lock_system, - &relative, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - match dav_fs.create_dir(&path).await { - Ok(()) => HttpResponse::Created() - .insert_header(( - header::CONTENT_LOCATION, - href_for_relative(prefix, &relative), - )) - .finish(), - Err(FsError::Exists) => responses::empty(StatusCode::METHOD_NOT_ALLOWED), - Err(FsError::NotFound) => responses::conflict(), - Err(err) => fs_error_response(err), - } -} - -pub(crate) async fn handle_delete( - req: &HttpRequest, - dav_fs: &fs::AsterDavFs, - lock_system: &dyn DavLockSystem, - prefix: &str, -) -> HttpResponse { - let depth = match protocol::parse_delete_depth(req.headers()) { - Ok(depth) => depth, - Err(resp) => return resp, - }; - - let (path, relative) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - - let meta = match dav_fs.metadata(&path).await { - Ok(meta) => meta, - Err(err) => return fs_error_response(err), - }; - if meta.is_dir() && !depth.is_infinity() { - return responses::bad_request(); - } - if let Err(resp) = protocol::evaluate_http_etag_preconditions( - req.headers(), - true, - meta.etag().as_deref(), - false, - ) { - return resp; - } - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if meta.is_dir() { - if let Some(resp) = - locked_multi_status_response(lock_system, &path, true, prefix, req).await - { - return resp; - } - } else if let Err(resp) = ensure_unlocked( - lock_system, - &path, - false, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if let Err(resp) = ensure_parent_unlocked( - lock_system, - &relative, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - let result = if meta.is_dir() { - dav_fs.remove_dir(&path).await - } else { - dav_fs.remove_file(&path).await - }; - match result { - Ok(()) => { - if lock_system.delete(&path).await.is_err() { - tracing::warn!( - path = %decoded_path_string(&path), - "failed to delete WebDAV locks after resource deletion" - ); - } - HttpResponse::NoContent().finish() - } - Err(err) => fs_error_response(err), - } -} - -pub(crate) async fn handle_copy_move( - req: &HttpRequest, - dav_fs: &fs::AsterDavFs, - lock_system: &dyn DavLockSystem, - prefix: &str, - system_file_policy: &system_file::SystemFileBlockPolicy, - is_move: bool, -) -> HttpResponse { - let depth = if is_move { - match protocol::parse_move_depth(req.headers()) { - Ok(depth) => depth, - Err(resp) => return resp, - } - } else { - match protocol::parse_copy_depth(req.headers()) { - Ok(depth) => depth, - Err(resp) => return resp, - } - }; - - let (source, source_relative) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - - let (request_scheme, request_host) = request_origin(req); - let destination_relative = match protocol::destination_relative_path( - req.headers(), - prefix, - &request_scheme, - &request_host, - ) { - Ok(path) => path, - Err(resp) => return resp, - }; - if same_resource_path(&source_relative, &destination_relative) { - return responses::forbidden(); - } - if let Err(resp) = ensure_system_file_name_allowed(system_file_policy, &destination_relative) { - return resp; - } - if let Err(resp) = ensure_parent_exists(dav_fs, &destination_relative).await { - return resp; - } - - let destination = match DavPath::new(&destination_relative) { - Ok(path) => path, - Err(_) => return responses::bad_request_text("Invalid destination path"), - }; - - let source_meta = match dav_fs.metadata(&source).await { - Ok(meta) => meta, - Err(err) => return fs_error_response(err), - }; - if let Err(resp) = protocol::evaluate_http_etag_preconditions( - req.headers(), - true, - source_meta.etag().as_deref(), - false, - ) { - return resp; - } - if source_meta.is_dir() { - if is_move && !depth.is_infinity() { - return responses::bad_request(); - } - if !is_move && depth == Depth::One { - return responses::bad_request(); - } - } - let recursive_collection_copy_or_move = - source_meta.is_dir() && (is_move || depth != Depth::Zero); - if recursive_collection_copy_or_move - && is_descendant_path(&source_relative, &destination_relative) - { - return responses::forbidden(); - } - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &source, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if is_move - && let Err(resp) = ensure_unlocked( - lock_system, - &source, - false, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if is_move - && let Err(resp) = ensure_parent_unlocked( - lock_system, - &source_relative, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - let destination_meta = match dav_fs.metadata(&destination).await { - Ok(meta) => Some(meta), - Err(FsError::NotFound) => None, - Err(err) => return fs_error_response(err), - }; - let destination_exists = destination_meta.is_some(); - let overwrite = match protocol::parse_overwrite(req.headers()) { - Ok(overwrite) => overwrite, - Err(resp) => return resp, - }; - if !overwrite && destination_exists { - return responses::precondition_failed(); - } - let destination_is_collection = destination_meta.as_ref().is_some_and(|meta| meta.is_dir()); - let destination_deep = - destination_is_collection || source_meta.is_dir() && (is_move || depth != Depth::Zero); - if !destination_deep - && let Err(resp) = ensure_unlocked( - lock_system, - &destination, - false, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if let Err(resp) = ensure_parent_unlocked( - lock_system, - &destination_relative, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - if recursive_collection_copy_or_move { - let source_conflicts = if is_move { - unsubmitted_lock_conflicts(lock_system, &source, true, prefix, req).await - } else { - Vec::new() - }; - let destination_conflicts = - unsubmitted_lock_conflicts(lock_system, &destination, true, prefix, req).await; - if !source_conflicts.is_empty() || !destination_conflicts.is_empty() { - let ctx = PartialMutationContext { - dav_fs, - lock_system, - req, - prefix, - is_move, - }; - let root = PartialMutationNode { - source, - source_relative, - destination, - destination_relative, - }; - let outcome = match partial_recursive_copy_move( - &ctx, - root, - destination_exists, - destination_is_collection, - ) - .await - { - Ok(outcome) => outcome, - Err(err) => return fs_error_response(err), - }; - if !outcome.failures.is_empty() { - return multi_status_failure_response(prefix, &outcome.failures); - } - if outcome.destination_exists { - return no_store_response(StatusCode::NO_CONTENT); - } - return no_store_response(StatusCode::CREATED); - } - } - - if destination_deep - && let Some(resp) = - locked_multi_status_response(lock_system, &destination, true, prefix, req).await - { - return resp; - } - - let result = if is_move { - dav_fs.rename(&source, &destination).await - } else if source_meta.is_dir() && depth == Depth::Zero { - dav_fs.copy_dir_shallow(&source, &destination).await - } else { - dav_fs.copy(&source, &destination).await - }; - - match result { - Ok(()) => { - if is_move && lock_system.delete(&source).await.is_err() { - tracing::warn!( - path = %source_relative, - "failed to delete WebDAV locks after move" - ); - } - if destination_exists { - no_store_response(StatusCode::NO_CONTENT) - } else { - no_store_response(StatusCode::CREATED) - } - } - Err(err) => fs_error_response(err), - } -} - -fn no_store_response(status: StatusCode) -> HttpResponse { - responses::no_store(status) -} - -async fn locked_multi_status_response( - lock_system: &dyn DavLockSystem, - path: &DavPath, - deep: bool, - prefix: &str, - req: &HttpRequest, -) -> Option { - let mut conflicts = lock_system.conflicting_locks(path, deep).await; - let (request_scheme, request_host) = request_origin(req); - conflicts.retain(|lock| { - let href = href_for_dav_path(prefix, &lock.path); - let tokens = protocol::submitted_lock_tokens_for_path( - req.headers(), - &href, - &request_scheme, - &request_host, - ); - !tokens.iter().any(|token| token == &lock.token) - }); - if conflicts.is_empty() { - return None; - } - - Some(multi_status_locked_response(prefix, &conflicts)) -} - -fn multi_status_locked_response( - prefix: &str, - locks: &[crate::webdav::dav::DavLock], -) -> HttpResponse { - let mut multistatus = dav_element("multistatus"); - multistatus - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - - for lock in locks { - let mut response = dav_element("response"); - response.children.push(XMLNode::Element(text_element( - "D:href", - &href_for_dav_path(prefix, &lock.path), - ))); - response - .children - .push(XMLNode::Element(status_element(StatusCode::LOCKED))); - let mut error = dav_element("error"); - error - .children - .push(XMLNode::Element(lock_token_submitted_element( - prefix, &lock.path, - ))); - response.children.push(XMLNode::Element(error)); - multistatus.children.push(XMLNode::Element(response)); - } - - responses::with_no_store(xml_response(multistatus, multi_status())) -} - -async fn unsubmitted_lock_conflicts( - lock_system: &dyn DavLockSystem, - path: &DavPath, - deep: bool, - prefix: &str, - req: &HttpRequest, -) -> Vec { - let mut conflicts = lock_system.conflicting_locks(path, deep).await; - let (request_scheme, request_host) = request_origin(req); - conflicts.retain(|lock| { - let href = href_for_dav_path(prefix, &lock.path); - let tokens = protocol::submitted_lock_tokens_for_path( - req.headers(), - &href, - &request_scheme, - &request_host, - ); - !tokens.iter().any(|token| token == &lock.token) - }); - conflicts -} - -async fn partial_recursive_copy_move( - ctx: &PartialMutationContext<'_>, - root: PartialMutationNode, - destination_exists: bool, - destination_is_collection: bool, -) -> Result { - let mut failures = Vec::new(); - if destination_exists && !destination_is_collection { - let conflicts = collect_lock_failures(ctx, &root.destination, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - return Ok(PartialMutationOutcome { - failures, - destination_exists, - }); - } - ctx.dav_fs.remove_file(&root.destination).await?; - ctx.dav_fs - .copy_dir_shallow(&root.source, &root.destination) - .await?; - } else if destination_exists && destination_is_collection { - let conflicts = collect_lock_failures(ctx, &root.destination, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - return Ok(PartialMutationOutcome { - failures, - destination_exists, - }); - } - } - - if !destination_exists { - ctx.dav_fs - .copy_dir_shallow(&root.source, &root.destination) - .await?; - } - - let mut work = Vec::new(); - work.push(PartialMutationWork::FinalizeDirectory(root.clone())); - push_directory_children(ctx, &root, &mut work).await?; - - while let Some(work_item) = work.pop() { - match work_item { - PartialMutationWork::ProcessFile(node) => { - partial_copy_move_file(ctx, &node, &mut failures).await?; - } - PartialMutationWork::VisitDirectory(node) => { - let dest_meta = match ctx.dav_fs.metadata(&node.destination).await { - Ok(meta) => Some(meta), - Err(FsError::NotFound) => None, - Err(err) => return Err(err), - }; - if dest_meta.as_ref().is_some_and(|meta| !meta.is_dir()) { - let conflicts = collect_lock_failures(ctx, &node.destination, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - continue; - } - ctx.dav_fs.remove_file(&node.destination).await?; - ctx.dav_fs - .copy_dir_shallow(&node.source, &node.destination) - .await?; - } else if dest_meta.as_ref().is_some_and(|meta| meta.is_dir()) { - let conflicts = collect_lock_failures(ctx, &node.destination, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - continue; - } - } else { - ctx.dav_fs - .copy_dir_shallow(&node.source, &node.destination) - .await?; - } - - work.push(PartialMutationWork::FinalizeDirectory(node.clone())); - push_directory_children(ctx, &node, &mut work).await?; - } - PartialMutationWork::FinalizeDirectory(node) if ctx.is_move => { - let conflicts = collect_lock_failures(ctx, &node.source, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - continue; - } - let remaining = - collect_children(ctx.dav_fs, &node.source, &node.source_relative).await?; - if remaining.is_empty() { - ctx.dav_fs.remove_dir(&node.source).await?; - if ctx.lock_system.delete(&node.source).await.is_err() { - tracing::warn!(path = %node.source_relative, "failed to delete WebDAV locks after partial move"); - } - } - } - PartialMutationWork::FinalizeDirectory(_) => {} - PartialMutationWork::RemoveDestinationFile(node) => { - let conflicts = collect_lock_failures(ctx, &node.path, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - continue; - } - ctx.dav_fs.remove_file(&node.path).await?; - } - PartialMutationWork::VisitDestinationDirectory(node) => { - work.push(PartialMutationWork::FinalizeDestinationDirectory( - node.clone(), - )); - push_destination_children(ctx, &node, &mut work).await?; - } - PartialMutationWork::FinalizeDestinationDirectory(node) => { - let conflicts = collect_lock_failures(ctx, &node.path, false).await; - if !conflicts.is_empty() { - extend_unique_failures(&mut failures, conflicts); - continue; - } - let remaining = collect_children(ctx.dav_fs, &node.path, &node.relative).await?; - if remaining.is_empty() { - ctx.dav_fs.remove_dir(&node.path).await?; - if ctx.lock_system.delete(&node.path).await.is_err() { - tracing::warn!(path = %node.relative, "failed to delete WebDAV locks after destination overwrite"); - } - } - } - } - } - - Ok(PartialMutationOutcome { - failures, - destination_exists, - }) -} - -async fn push_directory_children( - ctx: &PartialMutationContext<'_>, - node: &PartialMutationNode, - work: &mut Vec, -) -> Result<(), FsError> { - let children = collect_children(ctx.dav_fs, &node.source, &node.source_relative).await?; - let mut destination_nodes = HashMap::with_capacity(children.len()); - let mut source_work = Vec::with_capacity(children.len()); - for child in children { - let dest_relative = replace_relative_prefix( - &child.relative, - &node.source_relative, - &node.destination_relative, - ); - let dest_path = DavPath::new(&dest_relative).map_err(|_| FsError::BadRequest)?; - let child_node = PartialMutationNode { - source: child.path, - source_relative: child.relative, - destination: dest_path, - destination_relative: dest_relative, - }; - destination_nodes.insert( - resource_identity_path(&child_node.destination_relative), - child.is_dir, - ); - source_work.push(if child.is_dir { - PartialMutationWork::VisitDirectory(child_node) - } else { - PartialMutationWork::ProcessFile(child_node) - }); - } - for work_item in source_work.into_iter().rev() { - work.push(work_item); - } - - let destination_children = - collect_children(ctx.dav_fs, &node.destination, &node.destination_relative).await?; - for child in destination_children.into_iter().rev() { - if destination_nodes.get(&resource_identity_path(&child.relative)) == Some(&child.is_dir) { - continue; - } - let node = DestinationMutationNode { - path: child.path, - relative: child.relative, - }; - work.push(if child.is_dir { - PartialMutationWork::VisitDestinationDirectory(node) - } else { - PartialMutationWork::RemoveDestinationFile(node) - }); - } - Ok(()) -} - -async fn push_destination_children( - ctx: &PartialMutationContext<'_>, - node: &DestinationMutationNode, - work: &mut Vec, -) -> Result<(), FsError> { - let children = collect_children(ctx.dav_fs, &node.path, &node.relative).await?; - for child in children.into_iter().rev() { - let child_node = DestinationMutationNode { - path: child.path, - relative: child.relative, - }; - work.push(if child.is_dir { - PartialMutationWork::VisitDestinationDirectory(child_node) - } else { - PartialMutationWork::RemoveDestinationFile(child_node) - }); - } - Ok(()) -} - -async fn partial_copy_move_file( - ctx: &PartialMutationContext<'_>, - node: &PartialMutationNode, - failures: &mut Vec, -) -> Result<(), FsError> { - if ctx.is_move { - let conflicts = collect_lock_failures(ctx, &node.source, false).await; - if !conflicts.is_empty() { - extend_unique_failures(failures, conflicts); - return Ok(()); - } - } - let destination_is_collection = match ctx.dav_fs.metadata(&node.destination).await { - Ok(meta) => meta.is_dir(), - Err(FsError::NotFound) => false, - Err(err) => return Err(err), - }; - let dest_conflicts = - collect_lock_failures(ctx, &node.destination, destination_is_collection).await; - if !dest_conflicts.is_empty() { - extend_unique_failures(failures, dest_conflicts); - return Ok(()); - } - if ctx.is_move { - ctx.dav_fs.rename(&node.source, &node.destination).await?; - if ctx.lock_system.delete(&node.source).await.is_err() { - tracing::warn!(path = %decoded_path_string(&node.source), "failed to delete WebDAV locks after partial file move"); - } - } else { - ctx.dav_fs.copy(&node.source, &node.destination).await?; - } - Ok(()) -} - -fn extend_unique_failures( - failures: &mut Vec, - additions: impl IntoIterator, -) { - for failure in additions { - if failures.iter().any(|existing| { - existing.path == failure.path - && existing.status == failure.status - && existing.lock_path == failure.lock_path - }) { - continue; - } - failures.push(failure); - } -} - -async fn collect_lock_failures( - ctx: &PartialMutationContext<'_>, - path: &DavPath, - deep: bool, -) -> Vec { - unsubmitted_lock_conflicts(ctx.lock_system, path, deep, ctx.prefix, ctx.req) - .await - .into_iter() - .map(|lock| MultiStatusFailure { - path: (*lock.path).clone(), - status: StatusCode::LOCKED, - lock_path: Some((*lock.path).clone()), - }) - .collect() -} - -async fn collect_children( - dav_fs: &fs::AsterDavFs, - path: &DavPath, - relative: &str, -) -> Result, FsError> { - let entries = dav_fs.read_dir(path, ReadDirMeta::Data).await?; - pin_mut!(entries); - let mut children = Vec::new(); - while let Some(entry) = entries.next().await { - let entry = entry?; - let meta = entry.metadata().await?; - let child_relative = child_relative_path(relative, &entry.name(), meta.is_dir()); - let child_path = DavPath::new(&child_relative).map_err(|_| FsError::GeneralFailure)?; - children.push(DavChild { - path: child_path, - relative: child_relative, - is_dir: meta.is_dir(), - }); - } - Ok(children) -} - -fn replace_relative_prefix(path: &str, source_prefix: &str, destination_prefix: &str) -> String { - let source_prefix = source_prefix.trim_end_matches('/'); - let destination_prefix = destination_prefix.trim_end_matches('/'); - let suffix = path - .strip_prefix(source_prefix) - .filter(|suffix| suffix.is_empty() || suffix.starts_with('/')) - .unwrap_or(path); - if suffix.is_empty() { - format!("{destination_prefix}/") - } else { - format!("{destination_prefix}{suffix}") - } -} - -fn multi_status_failure_response(prefix: &str, failures: &[MultiStatusFailure]) -> HttpResponse { - let mut multistatus = dav_element("multistatus"); - multistatus - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - - for failure in failures { - let mut response = dav_element("response"); - response.children.push(XMLNode::Element(text_element( - "D:href", - &href_for_dav_path(prefix, &failure.path), - ))); - response - .children - .push(XMLNode::Element(status_element(failure.status))); - if failure.status == StatusCode::LOCKED { - let lock_path = failure.lock_path.as_ref().unwrap_or(&failure.path); - let mut error = dav_element("error"); - error - .children - .push(XMLNode::Element(lock_token_submitted_element( - prefix, lock_path, - ))); - response.children.push(XMLNode::Element(error)); - } - multistatus.children.push(XMLNode::Element(response)); - } - - responses::with_no_store(xml_response(multistatus, multi_status())) -} - -pub(crate) async fn ensure_empty_body(payload: &mut web::Payload) -> Result<(), HttpResponse> { - while let Some(chunk) = payload.next().await { - let chunk = chunk.map_err(|_| responses::request_body_read_error())?; - if !chunk.is_empty() { - return Err(responses::unsupported_media_type()); - } - } - Ok(()) -} - -async fn ensure_parent_exists(dav_fs: &fs::AsterDavFs, relative: &str) -> Result<(), HttpResponse> { - let Some(parent) = parent_relative_path(relative) else { - return Err(responses::empty(StatusCode::METHOD_NOT_ALLOWED)); - }; - if parent == "/" { - return Ok(()); - } - let parent_path = DavPath::new(&parent).map_err(|_| responses::bad_request())?; - match dav_fs.metadata(&parent_path).await { - Ok(meta) if meta.is_dir() => Ok(()), - Ok(_) => Err(responses::conflict()), - Err(FsError::NotFound) => Err(responses::conflict()), - Err(err) => Err(fs_error_response(err)), - } -} - -fn same_resource_path(left: &str, right: &str) -> bool { - resource_identity_path(left) == resource_identity_path(right) -} - -fn is_descendant_path(parent: &str, child: &str) -> bool { - let parent = resource_identity_path(parent); - let child = resource_identity_path(child); - if parent == "/" || parent == child { - return false; - } - let parent_prefix = format!("{parent}/"); - child.starts_with(&parent_prefix) -} - -fn resource_identity_path(path: &str) -> String { - let trimmed = path.trim_end_matches('/'); - if trimmed.is_empty() { - "/".to_string() - } else { - trimmed.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::{ - ensure_empty_body, is_descendant_path, replace_relative_prefix, same_resource_path, - }; - use actix_web::FromRequest; - use actix_web::http::StatusCode; - use actix_web::web; - use bytes::Bytes; - - async fn payload_from_bytes(bytes: Bytes) -> web::Payload { - let (req, mut dev_payload) = actix_web::test::TestRequest::default() - .set_payload(bytes) - .to_http_parts(); - web::Payload::from_request(&req, &mut dev_payload) - .await - .expect("test payload should extract") - } - - #[actix_web::test] - async fn ensure_empty_body_accepts_empty_payload() { - let mut payload = payload_from_bytes(Bytes::new()).await; - - ensure_empty_body(&mut payload) - .await - .expect("empty MKCOL body should be accepted"); - } - - #[actix_web::test] - async fn ensure_empty_body_ignores_empty_chunks() { - let mut payload = payload_from_bytes(Bytes::new()).await; - - ensure_empty_body(&mut payload) - .await - .expect("empty MKCOL body chunks should be accepted"); - } - - #[actix_web::test] - async fn ensure_empty_body_rejects_first_non_empty_chunk() { - let mut payload = payload_from_bytes(Bytes::from_static(b"x")).await; - - let response = ensure_empty_body(&mut payload) - .await - .expect_err("non-empty MKCOL body should be rejected"); - - assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - } - - #[actix_web::test] - async fn ensure_empty_body_stops_after_first_non_empty_chunk() { - let mut payload = payload_from_bytes(Bytes::from(vec![b'x'; 2 * 1024 * 1024])).await; - - let response = ensure_empty_body(&mut payload) - .await - .expect_err("large non-empty MKCOL body should be rejected"); - - assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - } - - #[test] - fn resource_identity_ignores_collection_trailing_slash() { - assert!(same_resource_path("/docs", "/docs/")); - assert!(same_resource_path("/", "/")); - assert!(!same_resource_path("/docs", "/docs/sub")); - } - - #[test] - fn descendant_identity_requires_path_boundary() { - assert!(is_descendant_path("/docs", "/docs/sub")); - assert!(is_descendant_path("/docs/", "/docs/sub/file.txt")); - assert!(!is_descendant_path("/docs", "/docs")); - assert!(!is_descendant_path("/docs", "/docs2/sub")); - assert!(!is_descendant_path("/", "/docs")); - } - - #[test] - fn replace_relative_prefix_strips_only_one_source_prefix() { - assert_eq!( - replace_relative_prefix("/docs/docs/file.txt", "/docs", "/archive"), - "/archive/docs/file.txt" - ); - } - - #[test] - fn replace_relative_prefix_handles_collection_root_and_trailing_slashes() { - assert_eq!( - replace_relative_prefix("/docs/", "/docs/", "/archive/"), - "/archive/" - ); - assert_eq!( - replace_relative_prefix("/docs/sub/", "/docs/", "/archive/"), - "/archive/sub/" - ); - } - - #[test] - fn replace_relative_prefix_leaves_unmatched_paths_attached_to_destination_prefix() { - assert_eq!( - replace_relative_prefix("/docs2/file.txt", "/docs", "/archive"), - "/archive/docs2/file.txt" - ); - } -} diff --git a/src/webdav/responses.rs b/src/webdav/responses.rs deleted file mode 100644 index 23d679ba5..000000000 --- a/src/webdav/responses.rs +++ /dev/null @@ -1,545 +0,0 @@ -//! Centralized WebDAV response builders. - -use actix_web::http::{StatusCode, header}; -use actix_web::{HttpResponse, HttpResponseBuilder}; -use xmltree::{Element, XMLNode}; - -use crate::webdav::dav::{DavPath, FsError}; -use crate::webdav::{dav_element, href_for_dav_path, text_element}; - -pub(crate) const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8"; -pub(crate) const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8"; -const NO_STORE: &str = "no-store"; - -pub(crate) fn build(status: StatusCode) -> HttpResponseBuilder { - let mut response = HttpResponse::build(status); - if status.is_client_error() || status.is_server_error() { - response.insert_header((header::CACHE_CONTROL, NO_STORE)); - } - response -} - -pub(crate) fn empty(status: StatusCode) -> HttpResponse { - build(status).finish() -} - -pub(crate) fn text(status: StatusCode, body: impl Into) -> HttpResponse { - build(status) - .content_type(TEXT_CONTENT_TYPE) - .body(body.into()) -} - -pub(crate) fn xml_response(root: Element, status: StatusCode) -> HttpResponse { - match xml_bytes(&root) { - Ok(body) => xml_body(status, body), - Err(resp) => resp, - } -} - -pub(crate) fn xml_response_builder( - root: Element, - status: StatusCode, -) -> Result<(HttpResponseBuilder, Vec), HttpResponse> { - xml_bytes(&root).map(|body| { - let mut builder = build(status); - builder.content_type(XML_CONTENT_TYPE); - (builder, body) - }) -} - -pub(crate) fn xml_body(status: StatusCode, body: Vec) -> HttpResponse { - build(status).content_type(XML_CONTENT_TYPE).body(body) -} - -pub(crate) fn xml_bytes(root: &Element) -> Result, HttpResponse> { - let mut buffer = Vec::new(); - root.write(&mut buffer) - .map_err(|_| empty(StatusCode::INTERNAL_SERVER_ERROR))?; - Ok(buffer) -} - -pub(crate) fn with_no_store(mut response: HttpResponse) -> HttpResponse { - response.headers_mut().insert( - header::CACHE_CONTROL, - header::HeaderValue::from_static(NO_STORE), - ); - response -} - -pub(crate) fn no_store(status: StatusCode) -> HttpResponse { - HttpResponse::build(status) - .insert_header((header::CACHE_CONTROL, NO_STORE)) - .finish() -} - -pub(crate) fn range_not_satisfiable(length: u64) -> HttpResponse { - build(StatusCode::RANGE_NOT_SATISFIABLE) - .insert_header((header::CONTENT_RANGE, format!("bytes */{length}"))) - .insert_header(("Accept-Ranges", "bytes")) - .finish() -} - -pub(crate) fn method_not_allowed(allow: &'static str) -> HttpResponse { - build(StatusCode::METHOD_NOT_ALLOWED) - .insert_header((header::ALLOW, allow)) - .finish() -} - -pub(crate) fn unauthorized() -> HttpResponse { - build(StatusCode::UNAUTHORIZED) - .insert_header(("WWW-Authenticate", "Basic realm=\"AsterDrive WebDAV\"")) - .content_type(TEXT_CONTENT_TYPE) - .body("Unauthorized") -} - -pub(crate) fn unauthorized_retry_after(retry_after: u64) -> HttpResponse { - build(StatusCode::UNAUTHORIZED) - .insert_header(("WWW-Authenticate", "Basic realm=\"AsterDrive WebDAV\"")) - .insert_header(("Retry-After", retry_after.to_string())) - .content_type(TEXT_CONTENT_TYPE) - .body("Unauthorized") -} - -pub(crate) fn bad_request() -> HttpResponse { - empty(StatusCode::BAD_REQUEST) -} - -pub(crate) fn bad_request_text(body: &'static str) -> HttpResponse { - text(StatusCode::BAD_REQUEST, body) -} - -pub(crate) fn conflict() -> HttpResponse { - empty(StatusCode::CONFLICT) -} - -pub(crate) fn forbidden() -> HttpResponse { - empty(StatusCode::FORBIDDEN) -} - -pub(crate) fn forbidden_text(body: &'static str) -> HttpResponse { - text(StatusCode::FORBIDDEN, body) -} - -pub(crate) fn precondition_failed() -> HttpResponse { - empty(StatusCode::PRECONDITION_FAILED) -} - -pub(crate) fn service_unavailable_text(body: &'static str) -> HttpResponse { - text(StatusCode::SERVICE_UNAVAILABLE, body) -} - -pub(crate) fn unsupported_media_type() -> HttpResponse { - empty(StatusCode::UNSUPPORTED_MEDIA_TYPE) -} - -pub(crate) fn payload_too_large_text(body: &'static str) -> HttpResponse { - text(StatusCode::PAYLOAD_TOO_LARGE, body) -} - -pub(crate) fn webdav_disabled() -> HttpResponse { - service_unavailable_text("WebDAV is disabled") -} - -pub(crate) fn request_body_read_error() -> HttpResponse { - bad_request_text("Failed to read request body") -} - -pub(crate) fn xml_body_too_large() -> HttpResponse { - payload_too_large_text("WebDAV XML body too large") -} - -pub(crate) fn invalid_xml_body() -> HttpResponse { - bad_request_text("Invalid XML body") -} - -pub(crate) fn no_external_entities() -> HttpResponse { - let mut error = dav_element("error"); - error - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - error - .children - .push(XMLNode::Element(dav_element("no-external-entities"))); - xml_response(error, StatusCode::FORBIDDEN) -} - -pub(crate) fn invalid_request_path() -> HttpResponse { - bad_request_text("Invalid request path") -} - -pub(crate) fn system_file_name_blocked() -> HttpResponse { - forbidden_text("WebDAV system file name is blocked") -} - -pub(crate) fn unsupported_root_proppatch() -> HttpResponse { - forbidden_text("PROPPATCH on the WebDAV mount root is not supported") -} - -pub(crate) fn webdav_lock_limit_exceeded() -> HttpResponse { - text( - StatusCode::INSUFFICIENT_STORAGE, - "WebDAV active lock limit exceeded", - ) -} - -pub(crate) fn fs_error_response(err: FsError) -> HttpResponse { - empty(fs_error_status(&err)) -} - -pub(crate) fn lock_token_submitted_element(prefix: &str, path: &DavPath) -> Element { - let mut submitted = dav_element("lock-token-submitted"); - submitted.children.push(XMLNode::Element(text_element( - "D:href", - &href_for_dav_path(prefix, path), - ))); - submitted -} - -pub(crate) fn lock_token_submitted_response( - status: StatusCode, - prefix: &str, - path: &DavPath, -) -> HttpResponse { - let mut error = dav_element("error"); - error - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - error - .children - .push(XMLNode::Element(lock_token_submitted_element(prefix, path))); - xml_response(error, status) -} - -pub(crate) fn lock_token_matches_request_uri_response(status: StatusCode) -> HttpResponse { - let mut error = dav_element("error"); - error - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - error.children.push(XMLNode::Element(dav_element( - "lock-token-matches-request-uri", - ))); - xml_response(error, status) -} - -pub(crate) fn propfind_finite_depth_response() -> HttpResponse { - let mut error = dav_element("error"); - error - .attributes - .insert("xmlns:D".to_string(), "DAV:".to_string()); - error - .children - .push(XMLNode::Element(dav_element("propfind-finite-depth"))); - xml_response(error, StatusCode::FORBIDDEN) -} - -fn fs_error_status(err: &FsError) -> StatusCode { - match err { - FsError::NotFound => StatusCode::NOT_FOUND, - FsError::Forbidden => StatusCode::FORBIDDEN, - FsError::Exists => StatusCode::CONFLICT, - FsError::InsufficientStorage => StatusCode::INSUFFICIENT_STORAGE, - FsError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE, - FsError::BadRequest => StatusCode::BAD_REQUEST, - FsError::GeneralFailure => StatusCode::INTERNAL_SERVER_ERROR, - } -} - -#[cfg(test)] -mod tests { - use actix_web::body; - use actix_web::http::{StatusCode, header}; - - use super::{ - XML_CONTENT_TYPE, fs_error_response, invalid_xml_body, - lock_token_matches_request_uri_response, lock_token_submitted_response, method_not_allowed, - no_external_entities, no_store, propfind_finite_depth_response, range_not_satisfiable, - request_body_read_error, system_file_name_blocked, text, unauthorized, - unauthorized_retry_after, unsupported_root_proppatch, with_no_store, xml_body_too_large, - xml_response, xml_response_builder, - }; - use crate::webdav::dav::{DavPath, FsError}; - use crate::webdav::dav_element; - - fn assert_no_store(response: &actix_web::HttpResponse) { - assert_eq!( - response.headers().get(header::CACHE_CONTROL), - Some(&header::HeaderValue::from_static("no-store")) - ); - } - - async fn body_text(response: actix_web::HttpResponse) -> String { - let bytes = body::to_bytes(response.into_body()) - .await - .expect("response body should be readable"); - String::from_utf8(bytes.to_vec()).expect("response body should be utf-8") - } - - #[test] - fn text_error_responses_are_plain_text_and_not_cacheable() { - let response = invalid_xml_body(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_no_store(&response); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&header::HeaderValue::from_static( - "text/plain; charset=utf-8" - )) - ); - } - - #[test] - fn xml_error_responses_are_xml_and_not_cacheable() { - let response = lock_token_matches_request_uri_response(StatusCode::CONFLICT); - - assert_eq!(response.status(), StatusCode::CONFLICT); - assert_no_store(&response); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&header::HeaderValue::from_static(XML_CONTENT_TYPE)) - ); - } - - #[actix_web::test] - async fn no_external_entities_uses_webdav_condition_response() { - let response = no_external_entities(); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert_no_store(&response); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&header::HeaderValue::from_static(XML_CONTENT_TYPE)) - ); - - let body = body_text(response).await; - assert!(body.contains("no-external-entities"), "{body}"); - assert!(body.contains("xmlns:D=\"DAV:\""), "{body}"); - } - - #[test] - fn successful_text_and_multistatus_xml_do_not_force_no_store() { - let text_response = text(StatusCode::OK, "Already under version control"); - let xml_response = xml_response(dav_element("multistatus"), StatusCode::MULTI_STATUS); - - assert_eq!(text_response.status(), StatusCode::OK); - assert!(text_response.headers().get(header::CACHE_CONTROL).is_none()); - assert_eq!(xml_response.status(), StatusCode::MULTI_STATUS); - assert!(xml_response.headers().get(header::CACHE_CONTROL).is_none()); - } - - #[actix_web::test] - async fn xml_response_builder_allows_headers_before_body() { - let (mut builder, body) = match xml_response_builder(dav_element("prop"), StatusCode::OK) { - Ok(parts) => parts, - Err(response) => panic!( - "xml response builder should serialize test XML, got {}", - response.status() - ), - }; - builder.insert_header(("Lock-Token", "")); - let response = builder.body(body); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&header::HeaderValue::from_static(XML_CONTENT_TYPE)) - ); - assert_eq!( - response.headers().get("Lock-Token"), - Some(&header::HeaderValue::from_static("")) - ); - - let body = body_text(response).await; - assert!(body.contains(""), "{body}"); - assert!(body.contains("/webdav/dir/space%20file.txt"), "{body}"); - } - - #[actix_web::test] - async fn propfind_finite_depth_xml_is_rfc4918_precondition_body() { - let response = propfind_finite_depth_response(); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert_no_store(&response); - - let body = body_text(response).await; - assert!(body.contains(""), "{body}"); - } - - #[actix_web::test] - async fn lock_token_matches_request_uri_xml_has_precondition_element() { - let response = lock_token_matches_request_uri_response(StatusCode::CONFLICT); - - assert_eq!(response.status(), StatusCode::CONFLICT); - assert_no_store(&response); - - let body = body_text(response).await; - assert!(body.contains(""), - "{body}" - ); - } -} diff --git a/src/webdav/system_file.rs b/src/webdav/system_file.rs index 815280b73..4bdd0285e 100644 --- a/src/webdav/system_file.rs +++ b/src/webdav/system_file.rs @@ -47,6 +47,12 @@ impl SystemFileBlockPolicy { } } +impl aster_forge_webdav::dav::DavNamePolicy for SystemFileBlockPolicy { + fn is_blocked_name(&self, name: &str) -> bool { + SystemFileBlockPolicy::is_blocked_name(self, name) + } +} + pub fn is_blocked_by_runtime_config(runtime_config: &RuntimeConfig, name: &str) -> bool { SystemFileBlockPolicy::from_runtime_config(runtime_config).is_blocked_name(name) } diff --git a/src/webdav/transfer/mod.rs b/src/webdav/transfer/mod.rs deleted file mode 100644 index ec87ed396..000000000 --- a/src/webdav/transfer/mod.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! WebDAV GET/HEAD/PUT transfer handlers. - -use std::time::Instant; - -use actix_web::http::{StatusCode, header}; -use actix_web::{HttpRequest, HttpResponse, web}; -use aster_forge_utils::http_validators::format_http_date; -use futures::StreamExt; -use tokio_util::io::ReaderStream; - -use crate::services::files::file; -use crate::webdav::dav::{DavFileSystem, DavLockSystem, DavMetaData, FsError, OpenOptions}; -use crate::webdav::{ - ensure_parent_unlocked, ensure_system_file_name_allowed, ensure_unlocked, fs, - fs_error_response, href_for_relative, protocol, request_origin, request_path, responses, - system_file, -}; -use protocol::HttpEtagPrecondition; - -const CHUNK_SIZE: usize = 64 * 1024; - -pub(crate) async fn handle_get_head( - req: &HttpRequest, - dav_fs: &fs::AsterDavFs, - lock_system: &dyn DavLockSystem, - prefix: &str, - head_only: bool, -) -> HttpResponse { - let request_started_at = Instant::now(); - let (path, relative) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - let resolve_started_at = Instant::now(); - let target = match dav_fs.resolve_download_target(&path).await { - Ok(target) => target, - Err(err) => return fs_error_response(err), - }; - let resolve_elapsed_ms = resolve_started_at.elapsed().as_millis(); - let Some(fs::AsterDavDownloadFile { file, blob, meta }) = target else { - return responses::empty(StatusCode::METHOD_NOT_ALLOWED); - }; - let last_modified = match meta.modified() { - Ok(modified) => modified, - Err(err) => return fs_error_response(err), - }; - let last_modified_header = format_http_date(last_modified); - let etag = meta.etag(); - match protocol::evaluate_http_download_preconditions( - req.headers(), - etag.as_deref(), - Some(last_modified), - ) { - Ok(HttpEtagPrecondition::Proceed) => {} - Ok(HttpEtagPrecondition::NotModified) => { - let mut response = HttpResponse::NotModified(); - response.insert_header((header::LAST_MODIFIED, last_modified_header)); - if let Some(etag) = etag { - response.insert_header((header::ETAG, format!("\"{etag}\""))); - } - return response.finish(); - } - Err(resp) => return resp, - } - - let content_type = meta - .content_type() - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - mime_guess::from_path(relative.trim_end_matches('/')) - .first_or_octet_stream() - .essence_str() - .to_string() - }); - let range = if head_only { - None - } else { - match file::parse_range_header( - req.headers().get(header::RANGE), - i64::try_from(meta.len()).unwrap_or(i64::MAX), - ) { - Ok(range) => range, - Err(_) => return range_not_satisfiable_response(meta.len()), - } - }; - let (status, content_length, content_range) = match range { - Some(range) => ( - StatusCode::PARTIAL_CONTENT, - range.length(), - Some(range.content_range_header()), - ), - None => (StatusCode::OK, meta.len(), None), - }; - - let mut response = HttpResponse::build(status); - response.insert_header((header::CONTENT_LENGTH, content_length.to_string())); - response.insert_header((header::CONTENT_TYPE, content_type)); - response.insert_header(("Accept-Ranges", "bytes")); - response.insert_header((header::CONTENT_ENCODING, "identity")); - response.insert_header((header::LAST_MODIFIED, last_modified_header)); - if let Some(content_range) = content_range { - response.insert_header((header::CONTENT_RANGE, content_range)); - } - if let Some(etag) = etag { - response.insert_header((header::ETAG, format!("\"{etag}\""))); - } - - if head_only { - return response.finish(); - } - - // GET must stream directly from storage; do not fall back to DavFileSystem::open(read). - let storage_started_at = Instant::now(); - let (range_offset, range_length) = range - .as_ref() - .map(|range| (Some(range.start()), Some(range.length()))) - .unwrap_or((None, None)); - let stream = match dav_fs - .open_download_stream_for_file(&file, &blob, range_offset, range_length) - .await - { - Ok(stream) => stream, - Err(err) => return fs_error_response(err), - }; - tracing::debug!( - path = %relative, - head_only, - has_range = range.is_some(), - content_length, - resolve_elapsed_ms, - storage_open_elapsed_ms = storage_started_at.elapsed().as_millis(), - total_prepare_elapsed_ms = request_started_at.elapsed().as_millis(), - "WebDAV GET/HEAD stream prepared" - ); - response.streaming(ReaderStream::with_capacity(stream, CHUNK_SIZE)) -} - -fn range_not_satisfiable_response(length: u64) -> HttpResponse { - responses::range_not_satisfiable(length) -} - -pub(crate) async fn handle_put( - req: &HttpRequest, - dav_fs: &fs::AsterDavFs, - lock_system: &dyn DavLockSystem, - prefix: &str, - system_file_policy: &system_file::SystemFileBlockPolicy, - payload: &mut web::Payload, -) -> HttpResponse { - let (path, relative) = match request_path(req, prefix) { - Ok(v) => v, - Err(resp) => return resp, - }; - if let Err(resp) = ensure_system_file_name_allowed(system_file_policy, &relative) { - return resp; - } - let existed = match dav_fs.metadata(&path).await { - Ok(meta) if meta.is_dir() => return responses::empty(StatusCode::METHOD_NOT_ALLOWED), - Ok(meta) => { - if let Err(resp) = protocol::evaluate_http_etag_preconditions( - req.headers(), - true, - meta.etag().as_deref(), - false, - ) { - return resp; - } - true - } - Err(FsError::NotFound) => { - if let Err(resp) = - protocol::evaluate_http_etag_preconditions(req.headers(), false, None, false) - { - return resp; - } - false - } - Err(err) => return fs_error_response(err), - }; - - let (request_scheme, request_host) = request_origin(req); - if let Err(resp) = protocol::ensure_if_header( - req.headers(), - dav_fs, - lock_system, - &path, - prefix, - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if let Err(resp) = ensure_unlocked( - lock_system, - &path, - false, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - if !existed - && let Err(resp) = ensure_parent_unlocked( - lock_system, - &relative, - prefix, - req.headers(), - &request_scheme, - &request_host, - ) - .await - { - return resp; - } - - let create_new = header_equals(req.headers(), header::IF_NONE_MATCH, "*"); - let create = !header_equals(req.headers(), header::IF_MATCH, "*"); - let mut options = OpenOptions::write(); - options.create = create; - options.create_new = create_new; - options.truncate = true; - options.size = content_length_hint(req.headers()); - - let mut file = match dav_fs.open(&path, options).await { - Ok(file) => file, - Err(FsError::Exists) => return responses::precondition_failed(), - Err(FsError::NotFound) => return responses::conflict(), - Err(err) => { - tracing::warn!(path = %relative, error = %err, "WebDAV PUT open failed"); - return fs_error_response(err); - } - }; - - while let Some(chunk) = payload.next().await { - let chunk = match chunk { - Ok(chunk) => chunk, - Err(_) => return responses::request_body_read_error(), - }; - if let Err(err) = file.write_bytes(chunk).await { - tracing::warn!(path = %relative, error = %err, "WebDAV PUT write failed"); - return fs_error_response(err); - } - } - - if let Err(err) = file.flush().await { - tracing::warn!(path = %relative, error = %err, "WebDAV PUT flush failed"); - return fs_error_response(err); - } - - if existed { - HttpResponse::NoContent().finish() - } else { - HttpResponse::Created() - .insert_header(( - header::CONTENT_LOCATION, - href_for_relative(prefix, &relative), - )) - .finish() - } -} - -fn content_length_hint(headers: &header::HeaderMap) -> Option { - headers - .get("X-Expected-Entity-Length") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()) - .or_else(|| { - headers - .get(header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()) - }) -} - -fn header_equals(headers: &header::HeaderMap, name: header::HeaderName, expected: &str) -> bool { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.trim() == expected) -} - -#[cfg(test)] -mod tests { - use actix_web::http::header::{self, HeaderMap, HeaderName, HeaderValue}; - - use super::content_length_hint; - - fn expected_entity_length_header() -> HeaderName { - HeaderName::from_static("x-expected-entity-length") - } - - #[test] - fn content_length_hint_prefers_valid_expected_entity_length() { - let mut headers = HeaderMap::new(); - headers.insert( - expected_entity_length_header(), - HeaderValue::from_static(" 8 "), - ); - headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("4")); - - assert_eq!(content_length_hint(&headers), Some(8)); - } - - #[test] - fn content_length_hint_falls_back_to_content_length_when_expected_is_invalid() { - let mut headers = HeaderMap::new(); - headers.insert( - expected_entity_length_header(), - HeaderValue::from_static("invalid"), - ); - headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("4")); - - assert_eq!(content_length_hint(&headers), Some(4)); - } - - #[test] - fn content_length_hint_returns_none_when_no_header_can_be_parsed() { - let mut headers = HeaderMap::new(); - headers.insert( - expected_entity_length_header(), - HeaderValue::from_static("-1"), - ); - headers.insert( - header::CONTENT_LENGTH, - HeaderValue::from_static("not-a-number"), - ); - - assert_eq!(content_length_hint(&headers), None); - } -} diff --git a/src/webdav/version_provider.rs b/src/webdav/version_provider.rs new file mode 100644 index 000000000..06fb8f76a --- /dev/null +++ b/src/webdav/version_provider.rs @@ -0,0 +1,123 @@ +//! AsterDrive persistence adapter for Forge's DeltaV protocol implementation. + +use sea_orm::DatabaseConnection; + +use crate::db::repository::{file_repo, user_repo, version_repo}; +use crate::services::workspace::storage::WorkspaceStorageScope; +use crate::webdav::auth::WebdavAuthResult; +use crate::webdav::path_resolver::{self, ResolvedNode}; +use aster_forge_utils::numbers::i64_to_u64; +use aster_forge_webdav::dav::{DavPath, LsFuture}; +use aster_forge_webdav::deltav::{ + DavVersionEntry, DavVersionError, DavVersionHistory, DavVersionProvider, +}; + +pub(crate) struct AsterDavVersionProvider<'a> { + db: &'a DatabaseConnection, + scope: WorkspaceStorageScope, + root_folder_id: Option, +} + +impl<'a> AsterDavVersionProvider<'a> { + pub(crate) fn new(db: &'a DatabaseConnection, auth: &WebdavAuthResult) -> Self { + Self { + db, + scope: auth.scope, + root_folder_id: auth.root_folder_id, + } + } +} + +impl DavVersionProvider for AsterDavVersionProvider<'_> { + fn version_history<'a>( + &'a self, + path: &'a DavPath, + ) -> LsFuture<'a, Result> { + Box::pin(async move { + let file = match path_resolver::resolve_path_in_scope( + self.db, + self.scope, + path, + self.root_folder_id, + ) + .await + .map_err(|_| DavVersionError::NotFound)? + { + ResolvedNode::File(file) => file, + ResolvedNode::Root | ResolvedNode::Folder(_) => { + return Err(DavVersionError::NotFile); + } + }; + + let versions = version_repo::find_by_file_id(self.db, file.id) + .await + .map_err(|_| DavVersionError::Backend)?; + let creator = match file.created_by_user_id { + Some(user_id) => user_repo::find_by_id(self.db, user_id) + .await + .map(|user| user.username) + .unwrap_or_else(|_| file.created_by_username.clone()), + None => file.created_by_username.clone(), + }; + let creator = if creator.is_empty() { + "unknown".to_string() + } else { + creator + }; + + let current = match file_repo::find_blob_by_id(self.db, file.blob_id).await { + Ok(blob) => Some(DavVersionEntry { + id: None, + size: i64_to_u64(blob.size, "WebDAV current version size") + .map_err(|_| DavVersionError::Backend)?, + modified: file.updated_at.into(), + creator: creator.clone(), + }), + Err(_) => None, + }; + + let blob_ids = versions + .iter() + .map(|version| version.blob_id) + .collect::>(); + let blobs = file_repo::find_blobs_by_ids(self.db, &blob_ids) + .await + .unwrap_or_default(); + let mut previous = Vec::with_capacity(versions.len()); + for version in versions { + let size = blobs + .get(&version.blob_id) + .map_or(version.size, |blob| blob.size); + previous.push(DavVersionEntry { + id: Some(version.version.to_string()), + size: i64_to_u64(size, "WebDAV historical version size") + .map_err(|_| DavVersionError::Backend)?, + modified: version.created_at.into(), + creator: creator.clone(), + }); + } + + Ok(DavVersionHistory { current, previous }) + }) + } + + fn validate_version_control<'a>( + &'a self, + path: &'a DavPath, + ) -> LsFuture<'a, Result<(), DavVersionError>> { + Box::pin(async move { + match path_resolver::resolve_path_in_scope( + self.db, + self.scope, + path, + self.root_folder_id, + ) + .await + { + Ok(ResolvedNode::File(_)) => Ok(()), + Ok(ResolvedNode::Root | ResolvedNode::Folder(_)) => Err(DavVersionError::NotFile), + Err(_) => Err(DavVersionError::NotFound), + } + }) + } +} diff --git a/tests/webdav/file.rs b/tests/webdav/file.rs index 119126c55..1d81fdd75 100644 --- a/tests/webdav/file.rs +++ b/tests/webdav/file.rs @@ -5,7 +5,7 @@ use crate::common; use std::io::SeekFrom; use aster_drive::runtime::SharedRuntimeState; -use aster_drive::webdav::dav::{DavFile, DavFileSystem, FsError, OpenOptions}; +use aster_forge_webdav::dav::{DavFile, DavFileSystem, FsError, OpenOptions}; use bytes::Bytes; fn write_temp_fixture(name: &str, contents: &str) -> String { @@ -132,8 +132,8 @@ async fn test_aster_dav_fs_reports_quota_and_roundtrips_custom_props() { use aster_drive::db::repository::user_repo; use aster_drive::services::files::file; use aster_drive::types::EntityType; - use aster_drive::webdav::dav::{DavFileSystem, DavPath, DavProp}; use aster_drive::webdav::fs::AsterDavFs; + use aster_forge_webdav::dav::{DavFileSystem, DavPath, DavProp}; use sea_orm::{ActiveModelTrait, Set}; let state = common::setup().await; @@ -271,8 +271,8 @@ async fn test_aster_dav_fs_reports_quota_and_roundtrips_custom_props() { #[actix_web::test] async fn test_aster_dav_fs_open_read_is_rejected_without_temp_files() { use aster_drive::services::files::file; - use aster_drive::webdav::dav::DavPath; use aster_drive::webdav::fs::AsterDavFs; + use aster_forge_webdav::dav::DavPath; let state = common::setup().await; let user = diff --git a/tests/webdav/lock_system.rs b/tests/webdav/lock_system.rs index 1f266c181..c939efa29 100644 --- a/tests/webdav/lock_system.rs +++ b/tests/webdav/lock_system.rs @@ -2,7 +2,7 @@ use crate::common; use aster_drive::runtime::SharedRuntimeState; -use aster_drive::webdav::dav::DavLockError; +use aster_forge_webdav::dav::DavLockError; use std::io::Cursor; use std::time::Duration; @@ -19,8 +19,8 @@ fn write_temp_fixture(name: &str, contents: &str) -> String { async fn test_db_lock_system_deep_lock_supports_check_refresh_discover_and_delete() { use aster_drive::db::repository::{folder_repo, lock_repo}; use aster_drive::services::{files::file, files::folder}; - use aster_drive::webdav::dav::{DavLockSystem, DavPath}; use aster_drive::webdav::db_lock_system::DbLockSystem; + use aster_forge_webdav::dav::{DavLockSystem, DavPath}; use xmltree::Element; let state = common::setup().await; @@ -140,8 +140,8 @@ async fn test_db_lock_system_deep_lock_supports_check_refresh_discover_and_delet async fn test_db_lock_system_rejects_unrepresentable_timeout() { use aster_drive::db::repository::lock_repo; use aster_drive::services::files::file; - use aster_drive::webdav::dav::{DavLockSystem, DavPath}; use aster_drive::webdav::db_lock_system::DbLockSystem; + use aster_forge_webdav::dav::{DavLockSystem, DavPath}; let state = common::setup().await; let user = common::create_test_account( @@ -197,8 +197,8 @@ async fn test_db_lock_system_replaces_expired_locks_and_rejects_active_conflicts use aster_drive::db::repository::{file_repo, lock_repo}; use aster_drive::services::{files::file, files::lock}; use aster_drive::types::EntityType; - use aster_drive::webdav::dav::{DavLockSystem, DavPath}; use aster_drive::webdav::db_lock_system::DbLockSystem; + use aster_forge_webdav::dav::{DavLockSystem, DavPath}; use chrono::Duration as ChronoDuration; let state = common::setup().await; @@ -324,8 +324,8 @@ async fn test_db_lock_system_allows_shared_locks_and_keeps_locked_until_last_unl use aster_drive::db::repository::{file_repo, lock_repo}; use aster_drive::services::files::file; use aster_drive::types::EntityType; - use aster_drive::webdav::dav::{DavLockSystem, DavPath}; use aster_drive::webdav::db_lock_system::DbLockSystem; + use aster_forge_webdav::dav::{DavLockSystem, DavPath}; let state = common::setup().await; let user = @@ -420,8 +420,8 @@ async fn test_db_lock_system_allows_shared_locks_and_keeps_locked_until_last_unl #[actix_web::test] async fn test_db_lock_system_exclusive_lock_blocks_shared_lock() { use aster_drive::services::files::file; - use aster_drive::webdav::dav::{DavLockSystem, DavPath}; use aster_drive::webdav::db_lock_system::DbLockSystem; + use aster_forge_webdav::dav::{DavLockSystem, DavPath}; let state = common::setup().await; let user = common::create_test_account( diff --git a/tests/webdav/path_resolver.rs b/tests/webdav/path_resolver.rs index f0bcf4b35..a2806fb6f 100644 --- a/tests/webdav/path_resolver.rs +++ b/tests/webdav/path_resolver.rs @@ -5,7 +5,7 @@ use crate::common; use std::time::Duration; use aster_drive::runtime::SharedRuntimeState; -use aster_drive::webdav::dav::{DavFileSystem, DavPath, FsError, OpenOptions, ReadDirMeta}; +use aster_forge_webdav::dav::{DavFileSystem, DavPath, FsError, OpenOptions, ReadDirMeta}; use bytes::Bytes; use futures::StreamExt; use sea_orm::{ActiveModelTrait, Set};