From 5631158b72ffd10bf7b89f9deb7033048deb53e0 Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Tue, 15 Jul 2025 06:14:57 +0200 Subject: [PATCH] completely refactor the backend --- backend/Cargo.lock | 677 +++++++++++++++++- backend/Cargo.toml | 4 + backend/src/bin/backend/main.rs | 42 +- backend/src/lib/api/mod.rs | 10 - backend/src/lib/api/state.rs | 21 - .../src/lib/api/warrens/create_directory.rs | 24 - .../src/lib/api/warrens/delete_directory.rs | 31 - .../src/lib/api/warrens/get_warren_path.rs | 27 - backend/src/lib/api/warrens/list_warrens.rs | 9 - backend/src/lib/api/warrens/mod.rs | 34 - backend/src/lib/api/warrens/upload_files.rs | 34 - backend/src/lib/config.rs | 68 ++ backend/src/lib/db/mod.rs | 28 - backend/src/lib/domain/file_system/mod.rs | 3 + .../src/lib/domain/file_system/models/file.rs | 332 +++++++++ .../src/lib/domain/file_system/models/mod.rs | 1 + backend/src/lib/domain/file_system/ports.rs | 80 +++ backend/src/lib/domain/file_system/service.rs | 116 +++ backend/src/lib/domain/mod.rs | 2 + backend/src/lib/domain/warren/mod.rs | 3 + backend/src/lib/domain/warren/models/mod.rs | 1 + .../src/lib/domain/warren/models/warren.rs | 359 ++++++++++ backend/src/lib/domain/warren/ports.rs | 129 ++++ backend/src/lib/domain/warren/service.rs | 223 ++++++ backend/src/lib/error.rs | 48 -- backend/src/lib/fs/dir.rs | 56 -- backend/src/lib/fs/file.rs | 29 - backend/src/lib/fs/mod.rs | 38 - backend/src/lib/inbound/http/handlers/mod.rs | 1 + .../warrens/create_warren_directory.rs | 86 +++ .../warrens/delete_warren_directory.rs | 88 +++ .../handlers/warrens/delete_warren_file.rs | 86 +++ .../http/handlers/warrens/fetch_warren.rs | 77 ++ .../handlers/warrens/list_warren_files.rs | 115 +++ .../http/handlers/warrens/list_warrens.rs | 65 ++ .../lib/inbound/http/handlers/warrens/mod.rs | 40 ++ .../handlers/warrens/upload_warren_files.rs | 121 ++++ backend/src/lib/inbound/http/mod.rs | 107 +++ backend/src/lib/inbound/http/responses.rs | 76 ++ backend/src/lib/inbound/mod.rs | 1 + backend/src/lib/lib.rs | 13 +- backend/src/lib/outbound/file_system.rs | 192 +++++ .../src/lib/outbound/metrics_debug_logger.rs | 110 +++ backend/src/lib/outbound/mod.rs | 4 + .../src/lib/outbound/notifier_debug_logger.rs | 96 +++ backend/src/lib/outbound/postgres.rs | 148 ++++ backend/src/lib/server.rs | 50 -- backend/src/lib/warrens/mod.rs | 4 +- frontend/components/DirectoryEntry.vue | 16 +- frontend/lib/api/warrens.ts | 160 +++-- frontend/types/api.ts | 4 + 51 files changed, 3563 insertions(+), 526 deletions(-) delete mode 100644 backend/src/lib/api/mod.rs delete mode 100644 backend/src/lib/api/state.rs delete mode 100644 backend/src/lib/api/warrens/create_directory.rs delete mode 100644 backend/src/lib/api/warrens/delete_directory.rs delete mode 100644 backend/src/lib/api/warrens/get_warren_path.rs delete mode 100644 backend/src/lib/api/warrens/list_warrens.rs delete mode 100644 backend/src/lib/api/warrens/mod.rs delete mode 100644 backend/src/lib/api/warrens/upload_files.rs create mode 100644 backend/src/lib/config.rs delete mode 100644 backend/src/lib/db/mod.rs create mode 100644 backend/src/lib/domain/file_system/mod.rs create mode 100644 backend/src/lib/domain/file_system/models/file.rs create mode 100644 backend/src/lib/domain/file_system/models/mod.rs create mode 100644 backend/src/lib/domain/file_system/ports.rs create mode 100644 backend/src/lib/domain/file_system/service.rs create mode 100644 backend/src/lib/domain/mod.rs create mode 100644 backend/src/lib/domain/warren/mod.rs create mode 100644 backend/src/lib/domain/warren/models/mod.rs create mode 100644 backend/src/lib/domain/warren/models/warren.rs create mode 100644 backend/src/lib/domain/warren/ports.rs create mode 100644 backend/src/lib/domain/warren/service.rs delete mode 100644 backend/src/lib/error.rs delete mode 100644 backend/src/lib/fs/dir.rs delete mode 100644 backend/src/lib/fs/file.rs delete mode 100644 backend/src/lib/fs/mod.rs create mode 100644 backend/src/lib/inbound/http/handlers/mod.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/mod.rs create mode 100644 backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs create mode 100644 backend/src/lib/inbound/http/mod.rs create mode 100644 backend/src/lib/inbound/http/responses.rs create mode 100644 backend/src/lib/inbound/mod.rs create mode 100644 backend/src/lib/outbound/file_system.rs create mode 100644 backend/src/lib/outbound/metrics_debug_logger.rs create mode 100644 backend/src/lib/outbound/mod.rs create mode 100644 backend/src/lib/outbound/notifier_debug_logger.rs create mode 100644 backend/src/lib/outbound/postgres.rs delete mode 100644 backend/src/lib/server.rs create mode 100644 frontend/types/api.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 52cc20b..217cdd1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -32,6 +43,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -82,6 +108,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "atoi" version = "2.0.0" @@ -152,6 +201,41 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum_typed_multipart" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7915d0c957ffd1a28edd7dff1f6d7b79c80e519b19b0961a8c80423504c45a82" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum_typed_multipart_macros", + "bytes", + "chrono", + "futures-core", + "futures-util", + "rust_decimal", + "tempfile", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "axum_typed_multipart_macros" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9e427c074d9f5355a2a7d969cf3a48dde6bf888b120937fb4c96ad2604b48a" +dependencies = [ + "darling", + "heck", + "proc-macro-error2", + "quote", + "syn 2.0.104", + "ubyte", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -188,6 +272,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -197,12 +293,57 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -215,12 +356,41 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cc" +version = "1.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -242,6 +412,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -291,6 +467,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.104", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.104", +] + [[package]] name = "der" version = "0.7.10" @@ -302,6 +513,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -322,7 +554,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -384,6 +616,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -406,6 +648,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "flume" version = "0.11.1" @@ -438,6 +686,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -482,6 +736,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -502,6 +767,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -528,7 +794,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -537,6 +815,15 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.4" @@ -554,7 +841,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.4", ] [[package]] @@ -683,6 +970,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -769,6 +1080,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -797,7 +1114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.4", ] [[package]] @@ -844,7 +1161,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -888,6 +1205,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "litemap" version = "0.8.0" @@ -964,7 +1287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -1169,6 +1492,37 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1178,6 +1532,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quote" version = "1.0.40" @@ -1187,6 +1561,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1214,7 +1600,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1255,6 +1641,44 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rsa" version = "0.9.8" @@ -1275,12 +1699,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust_decimal" +version = "1.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-demangle" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1299,6 +1752,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "serde" version = "1.0.219" @@ -1316,7 +1775,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1375,6 +1834,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -1394,6 +1859,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.10" @@ -1467,7 +1938,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.15.4", "hashlink", "indexmap", "log", @@ -1496,7 +1967,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.104", ] [[package]] @@ -1519,7 +1990,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.104", "tokio", "url", ] @@ -1647,12 +2118,29 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.104" @@ -1678,7 +2166,26 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", ] [[package]] @@ -1698,7 +2205,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1754,7 +2261,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1781,6 +2288,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.5.2" @@ -1855,7 +2379,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1873,6 +2397,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" + [[package]] name = "unicase" version = "2.8.1" @@ -1906,6 +2436,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.4" @@ -1956,11 +2492,15 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "warren" version = "0.1.0" dependencies = [ + "anyhow", "axum", + "axum_typed_multipart", + "derive_more", "dotenv", "env_logger", "log", "mime_guess", + "regex", "serde", "serde_json", "sqlx", @@ -1976,6 +2516,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -2004,7 +2553,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-shared", ] @@ -2026,7 +2575,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2050,6 +2599,65 @@ dependencies = [ "wasite", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2198,12 +2806,39 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + [[package]] name = "writeable" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.8.0" @@ -2224,7 +2859,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "synstructure", ] @@ -2245,7 +2880,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2265,7 +2900,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "synstructure", ] @@ -2305,5 +2940,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fc6c56e..5de4f65 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -12,11 +12,15 @@ name = "warren_backend" path = "src/bin/backend/main.rs" [dependencies] +anyhow = "1.0.98" axum = { version = "0.8.4", features = ["multipart", "query"] } +axum_typed_multipart = "0.16.3" +derive_more = { version = "2.0.1", features = ["display"] } dotenv = "0.15.0" env_logger = "0.11.8" log = "0.4.27" mime_guess = "2.0.5" +regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] } diff --git a/backend/src/bin/backend/main.rs b/backend/src/bin/backend/main.rs index 72e7057..939aaa1 100644 --- a/backend/src/bin/backend/main.rs +++ b/backend/src/bin/backend/main.rs @@ -1,19 +1,51 @@ -use warren::{server, Result}; +use warren::{ + config::Config, + domain, + inbound::http::{HttpServer, HttpServerConfig}, + outbound::{ + file_system::{FileSystem, FileSystemConfig}, + metrics_debug_logger::MetricsDebugLogger, + notifier_debug_logger::NotifierDebugLogger, + postgres::{Postgres, PostgresConfig}, + }, +}; #[tokio::main] -async fn main() -> Result<()> { +async fn main() -> anyhow::Result<()> { dotenv::dotenv().ok(); + let config = Config::from_env()?; + env_logger::builder() .format_target(false) .filter_level(log::LevelFilter::Info) - .parse_env("LOG_LEVEL") .parse_default_env() + .filter_level(config.log_level) .init(); - let pool = warren::db::get_postgres_pool().await?; + let metrics = MetricsDebugLogger::new(); + let notifier = NotifierDebugLogger::new(); - server::start(pool).await?; + let postgres_config = + PostgresConfig::new(config.database_url.clone(), config.database_name.clone()); + let postgres = Postgres::new(postgres_config).await?; + + let fs_config = FileSystemConfig::new(config.serve_dir); + let fs = FileSystem::new(fs_config)?; + let fs_service = domain::file_system::service::Service::new(fs, metrics, notifier); + + let warren_service = + domain::warren::service::Service::new(postgres, metrics, notifier, fs_service.clone()); + + let server_config = HttpServerConfig::new( + &config.server_address, + &config.server_port, + &config.cors_allow_origin, + config.static_frontend_dir.as_deref(), + ); + + let http_server = HttpServer::new(warren_service, server_config).await?; + http_server.run().await?; Ok(()) } diff --git a/backend/src/lib/api/mod.rs b/backend/src/lib/api/mod.rs deleted file mode 100644 index ed062e1..0000000 --- a/backend/src/lib/api/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::server::Router; - -mod state; -mod warrens; - -pub use state::AppState; - -pub(super) fn router() -> Router { - Router::new().nest("/warrens", warrens::router()) -} diff --git a/backend/src/lib/api/state.rs b/backend/src/lib/api/state.rs deleted file mode 100644 index c963a22..0000000 --- a/backend/src/lib/api/state.rs +++ /dev/null @@ -1,21 +0,0 @@ -use sqlx::{Pool, Postgres}; - -#[derive(Debug, Clone)] -pub struct AppState { - pool: Pool, - serve_dir: String, -} - -impl AppState { - pub fn new(pool: Pool, serve_dir: String) -> Self { - Self { pool, serve_dir } - } - - pub fn pool(&self) -> &Pool { - &self.pool - } - - pub fn serve_dir(&self) -> &str { - &self.serve_dir - } -} diff --git a/backend/src/lib/api/warrens/create_directory.rs b/backend/src/lib/api/warrens/create_directory.rs deleted file mode 100644 index 9896c7e..0000000 --- a/backend/src/lib/api/warrens/create_directory.rs +++ /dev/null @@ -1,24 +0,0 @@ -use axum::extract::{Path, State}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::{Result, api::AppState, warrens::Warren}; - -#[derive(Debug, Deserialize)] -pub(super) struct CreateWarrenDirectoryPath { - warren_id: Uuid, - rest: String, -} - -pub(super) async fn route( - State(state): State, - Path(path): Path, -) -> Result<()> { - let warren = Warren::get(state.pool(), &path.warren_id).await?; - - warren - .create_directory(state.serve_dir(), path.rest) - .await?; - - Ok(()) -} diff --git a/backend/src/lib/api/warrens/delete_directory.rs b/backend/src/lib/api/warrens/delete_directory.rs deleted file mode 100644 index 4f647d8..0000000 --- a/backend/src/lib/api/warrens/delete_directory.rs +++ /dev/null @@ -1,31 +0,0 @@ -use axum::extract::{Path, Query, State}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::{Result, api::AppState, fs::FileType, warrens::Warren}; - -#[derive(Deserialize)] -pub(super) struct DeleteWarrenDirectoryPath { - warren_id: Uuid, - rest: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteWarrenParams { - file_type: FileType, -} - -pub(super) async fn route( - State(state): State, - Path(path): Path, - Query(DeleteWarrenParams { file_type }): Query, -) -> Result<()> { - let warren = Warren::get(state.pool(), &path.warren_id).await?; - - warren - .delete_entry(state.serve_dir(), path.rest, file_type) - .await?; - - Ok(()) -} diff --git a/backend/src/lib/api/warrens/get_warren_path.rs b/backend/src/lib/api/warrens/get_warren_path.rs deleted file mode 100644 index 7c12d9a..0000000 --- a/backend/src/lib/api/warrens/get_warren_path.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axum::Json; -use axum::extract::{Path, State}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::Result; -use crate::api::AppState; -use crate::warrens::Warren; - -use crate::fs::DirectoryEntry; - -#[derive(Deserialize)] -pub(super) struct WarrenRequestPath { - warren_id: Uuid, - rest: Option, -} - -pub(super) async fn route( - State(state): State, - Path(WarrenRequestPath { warren_id, rest }): Path, -) -> Result>> { - let warren = Warren::get(state.pool(), &warren_id).await?; - - let entries = warren.read_path(state.serve_dir(), rest).await?; - - Ok(Json(entries)) -} diff --git a/backend/src/lib/api/warrens/list_warrens.rs b/backend/src/lib/api/warrens/list_warrens.rs deleted file mode 100644 index 26ca54f..0000000 --- a/backend/src/lib/api/warrens/list_warrens.rs +++ /dev/null @@ -1,9 +0,0 @@ -use axum::{Json, extract::State}; - -use crate::{Result, api::AppState, warrens::Warren}; - -pub(super) async fn route(State(state): State) -> Result>> { - let warrens = Warren::list(state.pool()).await?; - - Ok(Json(warrens)) -} diff --git a/backend/src/lib/api/warrens/mod.rs b/backend/src/lib/api/warrens/mod.rs deleted file mode 100644 index 4c7e45e..0000000 --- a/backend/src/lib/api/warrens/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -mod create_directory; -mod delete_directory; -mod get_warren_path; -mod list_warrens; -mod upload_files; - -use axum::{ - extract::DefaultBodyLimit, - routing::{delete, get, post}, -}; - -use crate::server::Router; - -pub(super) fn router() -> Router { - Router::new() - .route("/", get(list_warrens::route)) - .route("/{warren_id}/get", get(get_warren_path::route)) - .route("/{warren_id}/get/{*rest}", get(get_warren_path::route)) - .route("/{warren_id}/create/{*rest}", post(create_directory::route)) - .route( - "/{warren_id}/delete/{*rest}", - delete(delete_directory::route), - ) - .route( - "/{warren_id}/upload", - // 536870912 bytes = 0.5GB - post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)), - ) - .route( - "/{warren_id}/upload/{*rest}", - // 536870912 bytes = 0.5GB - post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)), - ) -} diff --git a/backend/src/lib/api/warrens/upload_files.rs b/backend/src/lib/api/warrens/upload_files.rs deleted file mode 100644 index 1b70aa2..0000000 --- a/backend/src/lib/api/warrens/upload_files.rs +++ /dev/null @@ -1,34 +0,0 @@ -use axum::extract::{Multipart, Path, State}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::{Result, api::AppState, warrens::Warren}; - -#[derive(Deserialize)] -pub(super) struct UploadPath { - warren_id: Uuid, - rest: Option, -} - -pub(super) async fn route( - State(state): State, - Path(UploadPath { warren_id, rest }): Path, - mut multipart: Multipart, -) -> Result<()> { - let warren = Warren::get(state.pool(), &warren_id).await?; - - while let Ok(Some(field)) = multipart.next_field().await { - if field.name().is_none_or(|name| name != "files") { - continue; - }; - - let file_name = field.file_name().map(str::to_owned).unwrap(); - let data = field.bytes().await?; - - warren - .upload(state.serve_dir(), rest.as_deref(), &file_name, &data) - .await?; - } - - Ok(()) -} diff --git a/backend/src/lib/config.rs b/backend/src/lib/config.rs new file mode 100644 index 0000000..c204927 --- /dev/null +++ b/backend/src/lib/config.rs @@ -0,0 +1,68 @@ +use std::{env, str::FromStr as _}; + +use anyhow::Context as _; +use log::LevelFilter; + +const DATABASE_URL_KEY: &str = "DATABASE_URL"; +const DATABASE_NAME_KEY: &str = "DATABASE_NAME"; + +const SERVER_ADDRESS_KEY: &str = "SERVER_ADDRESS"; +const SERVER_PORT_KEY: &str = "SERVER_PORT"; +const CORS_ALLOW_ORIGIN_KEY: &str = "CORS_ALLOW_ORIGIN"; + +const SERVE_DIRECTORY_KEY: &str = "SERVE_DIRECTORY"; + +const STATIC_FRONTEND_DIRECTORY: &str = "STATIC_FRONTEND_DIRECTORY"; + +const LOG_LEVEL_KEY: &str = "LOG_LEVEL"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub server_address: String, + pub server_port: u16, + pub cors_allow_origin: String, + + pub serve_dir: String, + pub static_frontend_dir: Option, + + pub database_url: String, + pub database_name: String, + + pub log_level: LevelFilter, +} + +impl Config { + pub fn from_env() -> anyhow::Result { + let server_address = load_env(SERVER_ADDRESS_KEY)?; + let server_port = load_env(SERVER_PORT_KEY)?.parse()?; + let cors_allow_origin = load_env(CORS_ALLOW_ORIGIN_KEY)?; + + let serve_dir = load_env(SERVE_DIRECTORY_KEY)?; + let static_frontend_dir = load_env(STATIC_FRONTEND_DIRECTORY).ok(); + + let database_url = load_env(DATABASE_URL_KEY)?; + let database_name = load_env(DATABASE_NAME_KEY)?; + + let log_level = + LevelFilter::from_str(&load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string())) + .context("Failed to convert the value of LOG_LEVEL to a LevelFilter")?; + + Ok(Self { + server_address, + server_port, + cors_allow_origin, + + serve_dir, + static_frontend_dir, + + database_url, + database_name, + + log_level, + }) + } +} + +fn load_env(key: &str) -> anyhow::Result { + env::var(key).context(format!("Failed to get environment variable: {key}")) +} diff --git a/backend/src/lib/db/mod.rs b/backend/src/lib/db/mod.rs deleted file mode 100644 index 027c65d..0000000 --- a/backend/src/lib/db/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::env; - -use sqlx::{Connection, PgConnection, Pool, Postgres, postgres::PgPoolOptions}; - -use crate::error::AppError; - -pub async fn get_postgres_pool() -> Result, AppError> { - let host = env::var("POSTGRES_HOST")?; - let port = env::var("POSTGRES_PORT")?; - let user = env::var("POSTGRES_USER")?; - let password = env::var("POSTGRES_PASSWORD")?; - let database = env::var("POSTGRES_DATABASE")?; - - let base_url = format!("postgres://{user}:{password}@{host}:{port}"); - - let mut connection = PgConnection::connect(&base_url).await?; - let _ = sqlx::query(&format!("CREATE DATABASE {database}")) - .execute(&mut connection) - .await; - connection.close().await?; - - let url = format!("{base_url}/{database}"); - - let pool = PgPoolOptions::new().connect(&url).await?; - sqlx::migrate!("./migrations").run(&pool).await?; - - Ok(pool) -} diff --git a/backend/src/lib/domain/file_system/mod.rs b/backend/src/lib/domain/file_system/mod.rs new file mode 100644 index 0000000..901e625 --- /dev/null +++ b/backend/src/lib/domain/file_system/mod.rs @@ -0,0 +1,3 @@ +pub mod models; +pub mod ports; +pub mod service; diff --git a/backend/src/lib/domain/file_system/models/file.rs b/backend/src/lib/domain/file_system/models/file.rs new file mode 100644 index 0000000..667c71a --- /dev/null +++ b/backend/src/lib/domain/file_system/models/file.rs @@ -0,0 +1,332 @@ +use std::{fmt::Display, path::Path}; + +use derive_more::Display; +use serde::Serialize; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct File { + name: FileName, + file_type: FileType, + mime_type: Option, +} + +impl File { + pub fn new(name: FileName, file_type: FileType, mime_type: Option) -> Self { + Self { + name, + file_type, + mime_type, + } + } + + pub fn name(&self) -> &FileName { + &self.name + } + + pub fn file_type(&self) -> &FileType { + &self.file_type + } + + pub fn mime_type(&self) -> Option<&FileMimeType> { + self.mime_type.as_ref() + } +} + +/// A valid file name +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)] +pub struct FileName(String); + +#[derive(Clone, Debug, Error)] +pub enum FileNameError { + #[error("A file name must not contain a slash")] + Slash, + #[error("A file name must not be empty")] + Empty, +} + +impl FileName { + pub fn new(raw: &str) -> Result { + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(FileNameError::Empty); + } + + if trimmed.contains("/") { + return Err(FileNameError::Slash); + } + + Ok(Self(trimmed.to_owned())) + } +} + +/// A valid file type +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FileType { + File, + Directory, +} + +impl Display for FileType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::File => "File", + Self::Directory => "Directory", + }) + } +} + +/// A valid file mime type +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)] +pub struct FileMimeType(String); + +impl FileMimeType { + pub fn from_name(name: &str) -> Option { + mime_guess::from_path(name) + .first_raw() + .map(|s| Self(s.to_owned())) + } +} + +/// A valid file path that might start with a slash +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)] +pub struct FilePath(String); + +/// A valid file path that does not start or end with a slash +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)] +pub struct RelativeFilePath(String); + +/// A valid file path that starts with a slash +/// In the context of this app absolute does not refer to the underlying file system but rather the +/// specified base directory +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)] +#[sqlx(transparent)] +pub struct AbsoluteFilePath(String); + +impl FilePath { + pub fn new(raw: &str) -> Result { + if raw.contains("//") || (raw.len() > 1 && raw.ends_with("/")) { + return Err(FilePathError::InvalidPath); + } + + Ok(Self(raw.to_owned())) + } + + pub fn join(&self, other: &RelativeFilePath) -> Self { + let mut path = self.0.clone(); + path.push('/'); + path.push_str(&other.0); + + Self(path) + } + + pub fn push(&mut self, other: &RelativeFilePath) { + self.0.push('/'); + self.0.push_str(&other.0); + } +} + +impl AsRef for FilePath { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} + +impl RelativeFilePath { + /* pub fn new(path: FilePath) -> Self { + Self(path.0.trim_start_matches('/').to_owned()) + } */ + + pub fn join(mut self, other: &RelativeFilePath) -> Self { + self.0.push('/'); + self.0.push_str(&other.0); + self + } +} + +impl AbsoluteFilePath { + /// Just trims the leading slash + pub fn to_relative(mut self) -> RelativeFilePath { + self.0.remove(0); + + RelativeFilePath(self.0) + } + + pub fn as_relative(&self) -> RelativeFilePath { + let mut path = self.0.clone(); + path.remove(0); + + RelativeFilePath(path) + } + + pub fn join(mut self, other: &RelativeFilePath) -> Self { + if !other.0.is_empty() { + self.0.push('/'); + self.0.push_str(&other.0); + } + + self + } +} + +#[derive(Clone, Debug, Error)] +pub enum FilePathError { + #[error("The provided path is invalid")] + InvalidPath, +} + +#[derive(Clone, Debug, Error)] +pub enum RelativeFilePathError { + #[error("A relative file path must not with a slash")] + Absolute, +} + +#[derive(Clone, Debug, Error)] +pub enum AbsoluteFilePathError { + #[error("An absolute file path must start with a slash")] + NotAbsolute, +} + +impl TryInto for FilePath { + type Error = AbsoluteFilePathError; + + fn try_into(self) -> Result { + if !self.0.starts_with("/") { + return Err(AbsoluteFilePathError::NotAbsolute); + } + + Ok(AbsoluteFilePath(self.0)) + } +} + +impl TryInto for FilePath { + type Error = RelativeFilePathError; + + fn try_into(self) -> Result { + if self.0.starts_with("/") { + return Err(RelativeFilePathError::Absolute); + } + + Ok(RelativeFilePath(self.0)) + } +} + +impl From for FilePath { + fn from(value: AbsoluteFilePath) -> Self { + Self(value.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListFilesRequest { + path: AbsoluteFilePath, +} + +impl ListFilesRequest { + pub fn new(path: AbsoluteFilePath) -> Self { + Self { path } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +#[derive(Debug, Error)] +pub enum ListFilesError { + #[error("Directory at path {0} does not exist")] + NotFound(String), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeleteDirectoryRequest { + path: AbsoluteFilePath, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeleteFileRequest { + path: AbsoluteFilePath, +} + +impl DeleteDirectoryRequest { + pub fn new(path: AbsoluteFilePath) -> Self { + Self { path } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +impl DeleteFileRequest { + pub fn new(path: AbsoluteFilePath) -> Self { + Self { path } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +#[derive(Debug, Error)] +pub enum DeleteDirectoryError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum DeleteFileError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateDirectoryRequest { + path: AbsoluteFilePath, +} + +impl CreateDirectoryRequest { + pub fn new(path: AbsoluteFilePath) -> Self { + Self { path } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +#[derive(Debug, Error)] +pub enum CreateDirectoryError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateFileRequest { + path: AbsoluteFilePath, + data: Box<[u8]>, +} + +impl CreateFileRequest { + pub fn new(path: AbsoluteFilePath, data: Box<[u8]>) -> Self { + Self { path, data } + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn data(&self) -> &[u8] { + &self.data + } +} + +#[derive(Debug, Error)] +pub enum CreateFileError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/file_system/models/mod.rs b/backend/src/lib/domain/file_system/models/mod.rs new file mode 100644 index 0000000..2e172cd --- /dev/null +++ b/backend/src/lib/domain/file_system/models/mod.rs @@ -0,0 +1 @@ +pub mod file; diff --git a/backend/src/lib/domain/file_system/ports.rs b/backend/src/lib/domain/file_system/ports.rs new file mode 100644 index 0000000..75e18d8 --- /dev/null +++ b/backend/src/lib/domain/file_system/ports.rs @@ -0,0 +1,80 @@ +use super::models::file::{ + CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, + DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, + FilePath, ListFilesError, ListFilesRequest, +}; + +pub trait FileSystemService: Clone + Send + Sync + 'static { + fn list_files( + &self, + request: ListFilesRequest, + ) -> impl Future, ListFilesError>> + Send; + + fn create_directory( + &self, + request: CreateDirectoryRequest, + ) -> impl Future> + Send; + fn delete_directory( + &self, + request: DeleteDirectoryRequest, + ) -> impl Future> + Send; + + fn create_file( + &self, + request: CreateFileRequest, + ) -> impl Future> + Send; + fn delete_file( + &self, + request: DeleteFileRequest, + ) -> impl Future> + Send; +} + +pub trait FileSystemRepository: Clone + Send + Sync + 'static { + fn list_files( + &self, + request: ListFilesRequest, + ) -> impl Future, ListFilesError>> + Send; + + fn create_directory( + &self, + request: CreateDirectoryRequest, + ) -> impl Future> + Send; + fn delete_directory( + &self, + request: DeleteDirectoryRequest, + ) -> impl Future> + Send; + + fn create_file( + &self, + request: CreateFileRequest, + ) -> impl Future> + Send; + fn delete_file( + &self, + request: DeleteFileRequest, + ) -> impl Future> + Send; +} + +pub trait FileSystemMetrics: Clone + Send + Sync + 'static { + fn record_list_files_success(&self) -> impl Future + Send; + fn record_list_files_failure(&self) -> impl Future + Send; + + fn record_directory_creation_success(&self) -> impl Future + Send; + fn record_directory_creation_failure(&self) -> impl Future + Send; + fn record_directory_deletion_success(&self) -> impl Future + Send; + fn record_directory_deletion_failure(&self) -> impl Future + Send; + + fn record_file_creation_success(&self) -> impl Future + Send; + fn record_file_creation_failure(&self) -> impl Future + Send; + fn record_file_deletion_success(&self) -> impl Future + Send; + fn record_file_deletion_failure(&self) -> impl Future + Send; +} + +pub trait FileSystemNotifier: Clone + Send + Sync + 'static { + fn files_listed(&self, files: &Vec) -> impl Future + Send; + + fn directory_created(&self, path: &FilePath) -> impl Future + Send; + fn directory_deleted(&self, path: &FilePath) -> impl Future + Send; + + fn file_created(&self, path: &FilePath) -> impl Future + Send; + fn file_deleted(&self, path: &FilePath) -> impl Future + Send; +} diff --git a/backend/src/lib/domain/file_system/service.rs b/backend/src/lib/domain/file_system/service.rs new file mode 100644 index 0000000..bc0225b --- /dev/null +++ b/backend/src/lib/domain/file_system/service.rs @@ -0,0 +1,116 @@ +use super::{ + models::file::{ + CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, + DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, FilePath, + ListFilesError, ListFilesRequest, + }, + ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService}, +}; + +#[derive(Debug, Clone)] +pub struct Service +where + R: FileSystemRepository, + M: FileSystemMetrics, + N: FileSystemNotifier, +{ + repository: R, + metrics: M, + notifier: N, +} + +impl Service +where + R: FileSystemRepository, + M: FileSystemMetrics, + N: FileSystemNotifier, +{ + pub fn new(repository: R, metrics: M, notifier: N) -> Self { + Self { + repository, + metrics, + notifier, + } + } +} + +impl FileSystemService for Service +where + R: FileSystemRepository, + M: FileSystemMetrics, + N: FileSystemNotifier, +{ + async fn list_files( + &self, + request: ListFilesRequest, + ) -> Result, ListFilesError> { + let result = self.repository.list_files(request).await; + + if let Ok(files) = result.as_ref() { + self.metrics.record_list_files_success().await; + self.notifier.files_listed(files).await; + } else { + self.metrics.record_list_files_failure().await; + } + + result + } + + async fn create_directory( + &self, + request: CreateDirectoryRequest, + ) -> Result { + let result = self.repository.create_directory(request).await; + + if let Ok(path) = result.as_ref() { + self.metrics.record_directory_creation_success().await; + self.notifier.directory_created(path).await; + } else { + self.metrics.record_directory_creation_failure().await; + } + + result + } + + async fn delete_directory( + &self, + request: DeleteDirectoryRequest, + ) -> Result { + let result = self.repository.delete_directory(request).await; + + if let Ok(path) = result.as_ref() { + self.metrics.record_directory_deletion_success().await; + self.notifier.directory_deleted(path).await; + } else { + self.metrics.record_directory_deletion_failure().await; + } + + result + } + + async fn create_file(&self, request: CreateFileRequest) -> Result { + let result = self.repository.create_file(request).await; + + if let Ok(path) = result.as_ref() { + self.metrics.record_file_creation_success().await; + self.notifier.file_created(path).await; + } else { + self.metrics.record_file_creation_failure().await; + } + + result + } + + async fn delete_file(&self, request: DeleteFileRequest) -> Result { + let result = self.repository.delete_file(request).await; + + if let Ok(path) = result.as_ref() { + self.metrics.record_file_deletion_success().await; + self.notifier.file_deleted(path).await; + } else { + self.metrics.record_file_deletion_failure().await; + } + + result + } +} diff --git a/backend/src/lib/domain/mod.rs b/backend/src/lib/domain/mod.rs new file mode 100644 index 0000000..a098c04 --- /dev/null +++ b/backend/src/lib/domain/mod.rs @@ -0,0 +1,2 @@ +pub mod file_system; +pub mod warren; diff --git a/backend/src/lib/domain/warren/mod.rs b/backend/src/lib/domain/warren/mod.rs new file mode 100644 index 0000000..901e625 --- /dev/null +++ b/backend/src/lib/domain/warren/mod.rs @@ -0,0 +1,3 @@ +pub mod models; +pub mod ports; +pub mod service; diff --git a/backend/src/lib/domain/warren/models/mod.rs b/backend/src/lib/domain/warren/models/mod.rs new file mode 100644 index 0000000..66a8f3b --- /dev/null +++ b/backend/src/lib/domain/warren/models/mod.rs @@ -0,0 +1 @@ +pub mod warren; diff --git a/backend/src/lib/domain/warren/models/warren.rs b/backend/src/lib/domain/warren/models/warren.rs new file mode 100644 index 0000000..9a5cd46 --- /dev/null +++ b/backend/src/lib/domain/warren/models/warren.rs @@ -0,0 +1,359 @@ +use derive_more::Display; +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::file_system::models::file::{ + AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, + CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, + DeleteFileRequest, FileName, FilePath, ListFilesError, ListFilesRequest, RelativeFilePath, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)] +pub struct Warren { + id: Uuid, + name: WarrenName, + path: AbsoluteFilePath, +} + +impl Warren { + pub fn new(id: Uuid, name: WarrenName, path: AbsoluteFilePath) -> Self { + Self { id, name, path } + } + + pub fn id(&self) -> &Uuid { + &self.id + } + + pub fn name(&self) -> &WarrenName { + &self.name + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +/// A valid warren name +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)] +#[sqlx(transparent)] +pub struct WarrenName(String); + +#[derive(Clone, Debug, Error)] +pub enum WarrenNameError { + #[error("A warren name must not be empty")] + Empty, +} + +impl WarrenName { + pub fn new(raw: &str) -> Result { + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(WarrenNameError::Empty); + } + + Ok(Self(trimmed.to_owned())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FetchWarrenRequest { + id: Uuid, +} + +impl FetchWarrenRequest { + pub fn new(id: Uuid) -> Self { + Self { id } + } + + pub fn id(&self) -> &Uuid { + &self.id + } +} + +#[derive(Debug, Error)] +pub enum FetchWarrenError { + #[error("Warren with id {0} does not exist")] + NotFound(Uuid), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListWarrensRequest; + +impl ListWarrensRequest { + pub fn new() -> Self { + Self {} + } +} + +#[derive(Debug, Error)] +pub enum ListWarrensError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListWarrenFilesRequest { + warren_id: Uuid, + path: AbsoluteFilePath, +} + +impl ListWarrenFilesRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self { + Self { warren_id, path } + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +impl Into for ListWarrenFilesRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +impl ListWarrenFilesRequest { + pub fn to_fs_request(self, warren: &Warren) -> ListFilesRequest { + let path = warren.path().clone().join(&self.path.to_relative()); + ListFilesRequest::new(path) + } +} + +#[derive(Debug, Error)] +pub enum ListWarrenFilesError { + #[error(transparent)] + FileSystem(#[from] ListFilesError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateWarrenDirectoryRequest { + warren_id: Uuid, + path: AbsoluteFilePath, +} + +impl CreateWarrenDirectoryRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self { + Self { warren_id, path } + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +impl Into for CreateWarrenDirectoryRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +impl CreateWarrenDirectoryRequest { + pub fn to_fs_request(self, warren: &Warren) -> CreateDirectoryRequest { + let path = warren.path().clone().join(&self.path.to_relative()); + CreateDirectoryRequest::new(path) + } +} + +#[derive(Debug, Error)] +pub enum CreateWarrenDirectoryError { + #[error(transparent)] + FileSystem(#[from] CreateDirectoryError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeleteWarrenDirectoryRequest { + warren_id: Uuid, + path: AbsoluteFilePath, + force: bool, +} + +impl DeleteWarrenDirectoryRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath, force: bool) -> Self { + Self { + warren_id, + path, + force, + } + } + + pub fn to_fs_request(self, warren: &Warren) -> DeleteDirectoryRequest { + let path = warren.path().clone().join(&self.path.to_relative()); + DeleteDirectoryRequest::new(path) + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn force(&self) -> bool { + self.force + } +} + +impl Into for &DeleteWarrenDirectoryRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +#[derive(Debug, Error)] +pub enum DeleteWarrenDirectoryError { + #[error(transparent)] + FileSystem(#[from] DeleteDirectoryError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeleteWarrenFileRequest { + warren_id: Uuid, + path: AbsoluteFilePath, +} + +impl DeleteWarrenFileRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self { + Self { warren_id, path } + } + + pub fn to_fs_request(self, warren: &Warren) -> DeleteFileRequest { + let path = warren.path().clone().join(&self.path.to_relative()); + DeleteFileRequest::new(path) + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } +} + +impl Into for &DeleteWarrenFileRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +#[derive(Debug, Error)] +pub enum DeleteWarrenFileError { + #[error(transparent)] + FileSystem(#[from] DeleteFileError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UploadWarrenFilesRequest { + warren_id: Uuid, + path: AbsoluteFilePath, + files: UploadFileList, +} + +impl UploadWarrenFilesRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath, files: UploadFileList) -> Self { + Self { + warren_id, + path, + files, + } + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn to_fs_requests(self, warren: &Warren) -> Vec { + let base_upload_path = self.path.as_relative(); + + self.files + .0 + .into_iter() + .map(|f| { + let file_name = FilePath::new(&f.file_name.to_string()).unwrap(); + let relative_file_path: RelativeFilePath = file_name.try_into().unwrap(); + let absolute_file_path = warren + .path() + .clone() + .join(&base_upload_path) + .join(&relative_file_path); + CreateFileRequest::new(absolute_file_path, f.data) + }) + .collect() + } +} + +impl Into for &UploadWarrenFilesRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +#[derive(Debug, Error)] +pub enum UploadWarrenFilesError { + #[error("Failed to upload the file at index {fail_index}")] + Partial { fail_index: usize }, + #[error(transparent)] + FileSystem(#[from] CreateFileError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UploadFileList(Vec); + +#[derive(Debug, Clone, Error)] +pub enum UploadFileListError { + #[error("The file list must not be empty")] + Empty, +} + +impl UploadFileList { + pub fn new(files: Vec) -> Result { + if files.len() < 1 { + return Err(UploadFileListError::Empty); + } + + Ok(Self(files)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UploadFile { + file_name: FileName, + data: Box<[u8]>, +} + +impl UploadFile { + pub fn new(file_name: FileName, data: Box<[u8]>) -> Self { + Self { file_name, data } + } + + pub fn file_name(&self) -> &FileName { + &self.file_name + } + + pub fn data(&self) -> &[u8] { + &self.data + } +} diff --git a/backend/src/lib/domain/warren/ports.rs b/backend/src/lib/domain/warren/ports.rs new file mode 100644 index 0000000..3d50b15 --- /dev/null +++ b/backend/src/lib/domain/warren/ports.rs @@ -0,0 +1,129 @@ +use crate::domain::file_system::models::file::{File, FilePath}; + +use super::models::warren::{ + CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError, + DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, FetchWarrenError, + FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest, ListWarrensError, + ListWarrensRequest, UploadWarrenFilesError, UploadWarrenFilesRequest, Warren, +}; + +pub trait WarrenService: Clone + Send + Sync + 'static { + fn list_warrens( + &self, + request: ListWarrensRequest, + ) -> impl Future, ListWarrensError>> + Send; + + fn fetch_warren( + &self, + request: FetchWarrenRequest, + ) -> impl Future> + Send; + + fn list_files( + &self, + request: ListWarrenFilesRequest, + ) -> impl Future, ListWarrenFilesError>> + Send; + + fn create_warren_directory( + &self, + request: CreateWarrenDirectoryRequest, + ) -> impl Future> + Send; + + fn delete_warren_directory( + &self, + request: DeleteWarrenDirectoryRequest, + ) -> impl Future> + Send; + + fn upload_warren_files( + &self, + request: UploadWarrenFilesRequest, + ) -> impl Future, UploadWarrenFilesError>> + Send; + fn delete_warren_file( + &self, + request: DeleteWarrenFileRequest, + ) -> impl Future> + Send; +} + +pub trait WarrenRepository: Clone + Send + Sync + 'static { + fn list_warrens( + &self, + request: ListWarrensRequest, + ) -> impl Future, ListWarrensError>> + Send; + + fn fetch_warren( + &self, + request: FetchWarrenRequest, + ) -> impl Future> + Send; +} + +pub trait WarrenMetrics: Clone + Send + Sync + 'static { + fn record_warren_list_success(&self) -> impl Future + Send; + fn record_warren_list_failure(&self) -> impl Future + Send; + + fn record_warren_fetch_success(&self) -> impl Future + Send; + fn record_warren_fetch_failure(&self) -> impl Future + Send; + + fn record_list_warren_files_success(&self) -> impl Future + Send; + fn record_list_warren_files_failure(&self) -> impl Future + Send; + + fn record_warren_directory_creation_success(&self) -> impl Future + Send; + fn record_warren_directory_creation_failure(&self) -> impl Future + Send; + + fn record_warren_directory_deletion_success(&self) -> impl Future + Send; + fn record_warren_directory_deletion_failure(&self) -> impl Future + Send; + + /// A single file upload succeeded + fn record_warren_file_upload_success(&self) -> impl Future + Send; + /// A single file upload failed + fn record_warren_file_upload_failure(&self) -> impl Future + Send; + + /// An upload succeeded fully + fn record_warren_files_upload_success(&self) -> impl Future + Send; + /// An upload failed at least partially + fn record_warren_files_upload_failure(&self) -> impl Future + Send; + + fn record_warren_file_deletion_success(&self) -> impl Future + Send; + fn record_warren_file_deletion_failure(&self) -> impl Future + Send; +} + +pub trait WarrenNotifier: Clone + Send + Sync + 'static { + fn warrens_listed(&self, warrens: &Vec) -> impl Future + Send; + fn warren_fetched(&self, warren: &Warren) -> impl Future + Send; + fn warren_files_listed( + &self, + warren: &Warren, + files: &Vec, + ) -> impl Future + Send; + fn warren_directory_created( + &self, + warren: &Warren, + path: &FilePath, + ) -> impl Future + Send; + fn warren_directory_deleted( + &self, + warren: &Warren, + path: &FilePath, + ) -> impl Future + Send; + /// A single file was uploaded + /// + /// * `warren`: The warren the file was uploaded to + /// * `path`: The file's path + fn warren_file_uploaded( + &self, + warren: &Warren, + path: &FilePath, + ) -> impl Future + Send; + /// A collection of files was uploaded + /// + /// * `warren`: The warren the file was uploaded to + /// * `files`: The files' paths + fn warren_files_uploaded( + &self, + warren: &Warren, + files: &[FilePath], + ) -> impl Future + Send; + fn warren_file_deleted( + &self, + warren: &Warren, + path: &FilePath, + ) -> impl Future + Send; +} diff --git a/backend/src/lib/domain/warren/service.rs b/backend/src/lib/domain/warren/service.rs new file mode 100644 index 0000000..cc36160 --- /dev/null +++ b/backend/src/lib/domain/warren/service.rs @@ -0,0 +1,223 @@ +use anyhow::Context; + +use crate::domain::file_system::{ + models::file::{File, FilePath}, + ports::FileSystemService, +}; + +use super::{ + models::warren::{ + CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError, + DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, + FetchWarrenError, FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest, + UploadWarrenFilesError, UploadWarrenFilesRequest, Warren, + }, + ports::{WarrenMetrics, WarrenNotifier, WarrenRepository, WarrenService}, +}; + +#[derive(Debug, Clone)] +pub struct Service +where + R: WarrenRepository, + M: WarrenMetrics, + N: WarrenNotifier, + FSS: FileSystemService, +{ + repository: R, + metrics: M, + notifier: N, + fs_service: FSS, +} + +impl Service +where + R: WarrenRepository, + M: WarrenMetrics, + N: WarrenNotifier, + FSS: FileSystemService, +{ + pub fn new(repository: R, metrics: M, notifier: N, fs_service: FSS) -> Self { + Self { + repository, + metrics, + notifier, + fs_service, + } + } +} + +impl WarrenService for Service +where + R: WarrenRepository, + M: WarrenMetrics, + N: WarrenNotifier, + FSS: FileSystemService, +{ + async fn list_warrens( + &self, + request: super::models::warren::ListWarrensRequest, + ) -> Result, super::models::warren::ListWarrensError> { + let result = self.repository.list_warrens(request).await; + + if let Ok(warren) = result.as_ref() { + self.metrics.record_warren_list_success().await; + self.notifier.warrens_listed(warren).await; + } else { + self.metrics.record_warren_list_failure().await; + } + + result + } + + async fn fetch_warren(&self, request: FetchWarrenRequest) -> Result { + let result = self.repository.fetch_warren(request).await; + + if let Ok(warren) = result.as_ref() { + self.metrics.record_warren_fetch_success().await; + self.notifier.warren_fetched(warren).await; + } else { + self.metrics.record_warren_fetch_failure().await; + } + + result + } + + async fn list_files( + &self, + request: ListWarrenFilesRequest, + ) -> Result, ListWarrenFilesError> { + let warren = self + .repository + .fetch_warren(request.clone().into()) + .await + .context("Failed to fetch warren")?; + + let result = self + .fs_service + .list_files(request.to_fs_request(&warren)) + .await; + + if let Ok(files) = result.as_ref() { + self.metrics.record_list_warren_files_success().await; + self.notifier.warren_files_listed(&warren, files).await; + } else { + self.metrics.record_list_warren_files_failure().await; + } + + result.map_err(Into::into) + } + + async fn create_warren_directory( + &self, + request: CreateWarrenDirectoryRequest, + ) -> Result { + let warren = self + .repository + .fetch_warren(request.clone().into()) + .await + .context("Failed to fetch warren")?; + + let result = self + .fs_service + .create_directory(request.to_fs_request(&warren)) + .await; + + if let Ok(path) = result.as_ref() { + self.metrics + .record_warren_directory_creation_success() + .await; + self.notifier.warren_directory_created(&warren, path).await; + } else { + self.metrics + .record_warren_directory_creation_failure() + .await; + } + + result.map_err(Into::into) + } + + async fn delete_warren_directory( + &self, + request: DeleteWarrenDirectoryRequest, + ) -> Result { + let warren = self + .repository + .fetch_warren((&request).into()) + .await + .context("Failed to fetch warren")?; + + let result = self + .fs_service + .delete_directory(request.to_fs_request(&warren)) + .await; + + if let Ok(path) = result.as_ref() { + self.metrics + .record_warren_directory_deletion_success() + .await; + self.notifier.warren_directory_deleted(&warren, path).await; + } else { + self.metrics + .record_warren_directory_deletion_failure() + .await; + } + + result.map_err(Into::into) + } + + // TODO: Improve this + async fn upload_warren_files( + &self, + request: UploadWarrenFilesRequest, + ) -> Result, UploadWarrenFilesError> { + let warren = self + .repository + .fetch_warren((&request).into()) + .await + .context("Failed to fetch warren")?; + + let fs_requests = request.to_fs_requests(&warren); + + let mut paths = Vec::with_capacity(fs_requests.len()); + + for (i, req) in fs_requests.into_iter().enumerate() { + let result = self.fs_service.create_file(req).await; + + let Ok(file_path) = result else { + self.metrics.record_warren_file_upload_failure().await; + self.metrics.record_warren_files_upload_failure().await; + return Err(UploadWarrenFilesError::Partial { fail_index: i }); + }; + + self.metrics.record_warren_file_upload_success().await; + self.notifier + .warren_file_uploaded(&warren, &file_path) + .await; + + paths.push(file_path); + } + + self.metrics.record_warren_files_upload_success().await; + self.notifier.warren_files_uploaded(&warren, &paths).await; + + Ok(paths) + } + + async fn delete_warren_file( + &self, + request: DeleteWarrenFileRequest, + ) -> Result { + let warren = self + .repository + .fetch_warren((&request).into()) + .await + .context("Failed to fetch warren")?; + + let result = self + .fs_service + .delete_file(request.to_fs_request(&warren)) + .await; + + result.map_err(Into::into) + } +} diff --git a/backend/src/lib/error.rs b/backend/src/lib/error.rs deleted file mode 100644 index 3e37232..0000000 --- a/backend/src/lib/error.rs +++ /dev/null @@ -1,48 +0,0 @@ -use axum::{ - body::Body, - extract::multipart::MultipartError, - http::{StatusCode, header::InvalidHeaderValue}, - response::{IntoResponse, Response}, -}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum AppError { - #[error("IO: {0}")] - Io(#[from] std::io::Error), - #[error("Could not get file name")] - FileName, - #[error("SQLx: {0}")] - Sqlx(#[from] sqlx::Error), - #[error("Failed to migrate the database")] - DatabaseMigration(#[from] sqlx::migrate::MigrateError), - #[error("Var: {0}")] - Var(#[from] std::env::VarError), - #[error("InvalidHeaderValue: {0}")] - InvalidHeaderValue(#[from] InvalidHeaderValue), - #[error("Multipart: {0}")] - Multipart(#[from] MultipartError), -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let status = match self { - Self::Io(error) => match error.kind() { - std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - Self::FileName => StatusCode::INTERNAL_SERVER_ERROR, - // TODO: Improve - Self::Sqlx(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Multipart(_) => StatusCode::INTERNAL_SERVER_ERROR, - }; - - Response::builder() - .status(status) - .body(Body::empty()) - .unwrap() - } -} diff --git a/backend/src/lib/fs/dir.rs b/backend/src/lib/fs/dir.rs deleted file mode 100644 index 4b71547..0000000 --- a/backend/src/lib/fs/dir.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::path::Path; - -use tokio::fs; - -use crate::{Result, error::AppError}; - -use super::{DirectoryEntry, FileType}; - -pub async fn get_dir_entries

(path: P) -> Result> -where - P: AsRef, -{ - let mut dir = fs::read_dir(path).await?; - - let mut files = Vec::new(); - - while let Ok(Some(entry)) = dir.next_entry().await { - let name = entry - .file_name() - .into_string() - .map_err(|_| AppError::FileName)?; - let file_type = { - let file_type = entry.file_type().await?; - - if file_type.is_dir() { - FileType::Directory - } else if file_type.is_file() { - FileType::File - } else { - continue; - } - }; - - files.push(DirectoryEntry::new(name, file_type)); - } - - Ok(files) -} - -pub async fn create_dir

(path: P) -> Result<()> -where - P: AsRef, -{ - fs::create_dir(path).await?; - - Ok(()) -} - -pub async fn delete_dir

(path: P) -> Result<()> -where - P: AsRef, -{ - fs::remove_dir_all(path).await?; - - Ok(()) -} diff --git a/backend/src/lib/fs/file.rs b/backend/src/lib/fs/file.rs deleted file mode 100644 index 41db4c7..0000000 --- a/backend/src/lib/fs/file.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::path::Path; - -use tokio::{fs, io::AsyncWriteExt}; - -use crate::Result; - -pub async fn write_file

(path: P, data: &[u8]) -> Result<()> -where - P: AsRef, -{ - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .open(path) - .await?; - - file.write_all(data).await?; - - Ok(()) -} - -pub async fn delete_file

(path: P) -> Result<()> -where - P: AsRef, -{ - fs::remove_file(path).await?; - - Ok(()) -} diff --git a/backend/src/lib/fs/mod.rs b/backend/src/lib/fs/mod.rs deleted file mode 100644 index ca3b4ca..0000000 --- a/backend/src/lib/fs/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod dir; -mod file; -pub use dir::*; -pub use file::*; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FileType { - File, - Directory, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DirectoryEntry { - name: String, - file_type: FileType, - mime_type: Option, -} - -impl DirectoryEntry { - pub fn new(name: String, file_type: FileType) -> Self { - let mime_type = match name.split("/").last() { - Some(last) if last.contains(".") => { - mime_guess::from_path(&name).first_raw().map(str::to_owned) - } - _ => None, - }; - - Self { - name, - file_type, - mime_type, - } - } -} diff --git a/backend/src/lib/inbound/http/handlers/mod.rs b/backend/src/lib/inbound/http/handlers/mod.rs new file mode 100644 index 0000000..09789d5 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/mod.rs @@ -0,0 +1 @@ +pub mod warrens; diff --git a/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs b/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs new file mode 100644 index 0000000..1d53676 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs @@ -0,0 +1,86 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::{ + file_system::models::file::{AbsoluteFilePathError, FilePath, FilePathError}, + warren::{ + models::warren::{CreateWarrenDirectoryError, CreateWarrenDirectoryRequest}, + ports::WarrenService, + }, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateWarrenDirectoryHttpRequestBody { + warren_id: Uuid, + path: String, +} + +#[derive(Debug, Clone, Error)] +pub enum ParseCreateWarrenDirectoryHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +impl From for ApiError { + fn from(value: ParseCreateWarrenDirectoryHttpRequestError) -> Self { + match value { + ParseCreateWarrenDirectoryHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => { + ApiError::BadRequest("The file path must be valid".to_string()) + } + }, + ParseCreateWarrenDirectoryHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + ApiError::BadRequest("The file path must be absolute".to_string()) + } + }, + } + } +} + +impl CreateWarrenDirectoryHttpRequestBody { + fn try_into_domain( + self, + ) -> Result { + let path = FilePath::new(&self.path)?; + + Ok(CreateWarrenDirectoryRequest::new( + self.warren_id, + path.try_into()?, + )) + } +} + +impl From for ApiError { + fn from(_value: CreateWarrenDirectoryError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +pub async fn create_warren_directory( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> +where + WS: WarrenService, +{ + let domain_request = request.try_into_domain()?; + + state + .warren_service + .create_warren_directory(domain_request) + .await + .map(|_| ApiSuccess::new(StatusCode::CREATED, ())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs new file mode 100644 index 0000000..adcb243 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs @@ -0,0 +1,88 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::{ + file_system::models::file::{AbsoluteFilePathError, FilePath, FilePathError}, + warren::{ + models::warren::{DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest}, + ports::WarrenService, + }, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteWarrenDirectoryHttpRequestBody { + warren_id: Uuid, + path: String, + force: bool, +} + +#[derive(Debug, Clone, Error)] +pub enum ParseDeleteWarrenDirectoryHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +impl From for ApiError { + fn from(value: ParseDeleteWarrenDirectoryHttpRequestError) -> Self { + match value { + ParseDeleteWarrenDirectoryHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => { + ApiError::BadRequest("The file path must be valid".to_string()) + } + }, + ParseDeleteWarrenDirectoryHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + ApiError::BadRequest("The file path must be absolute".to_string()) + } + }, + } + } +} + +impl DeleteWarrenDirectoryHttpRequestBody { + fn try_into_domain( + self, + ) -> Result { + let path = FilePath::new(&self.path)?; + + Ok(DeleteWarrenDirectoryRequest::new( + self.warren_id, + path.try_into()?, + self.force, + )) + } +} + +impl From for ApiError { + fn from(_value: DeleteWarrenDirectoryError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +pub async fn delete_warren_directory( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> +where + WS: WarrenService, +{ + let domain_request = request.try_into_domain()?; + + state + .warren_service + .delete_warren_directory(domain_request) + .await + .map(|_| ApiSuccess::new(StatusCode::CREATED, ())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs new file mode 100644 index 0000000..cf93610 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs @@ -0,0 +1,86 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::{ + file_system::models::file::{AbsoluteFilePathError, FilePath, FilePathError}, + warren::{ + models::warren::{DeleteWarrenFileError, DeleteWarrenFileRequest}, + ports::WarrenService, + }, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteWarrenFileHttpRequestBody { + warren_id: Uuid, + path: String, +} + +#[derive(Debug, Clone, Error)] +pub enum ParseDeleteWarrenFileHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +impl From for ApiError { + fn from(value: ParseDeleteWarrenFileHttpRequestError) -> Self { + match value { + ParseDeleteWarrenFileHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => { + ApiError::BadRequest("The file path must be valid".to_string()) + } + }, + ParseDeleteWarrenFileHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + ApiError::BadRequest("The file path must be absolute".to_string()) + } + }, + } + } +} + +impl DeleteWarrenFileHttpRequestBody { + fn try_into_domain( + self, + ) -> Result { + let path = FilePath::new(&self.path)?; + + Ok(DeleteWarrenFileRequest::new( + self.warren_id, + path.try_into()?, + )) + } +} + +impl From for ApiError { + fn from(_value: DeleteWarrenFileError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +pub async fn delete_warren_file( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> +where + WS: WarrenService, +{ + let domain_request = request.try_into_domain()?; + + state + .warren_service + .delete_warren_file(domain_request) + .await + .map(|_| ApiSuccess::new(StatusCode::CREATED, ())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs b/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs new file mode 100644 index 0000000..34f1b80 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs @@ -0,0 +1,77 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::warren::{FetchWarrenError, FetchWarrenRequest, Warren}, + ports::WarrenService, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FetchWarrenResponseData { + id: Uuid, + name: String, +} + +impl Into for &Warren { + fn into(self) -> FetchWarrenResponseData { + FetchWarrenResponseData { + id: *self.id(), + name: self.name().to_string(), + } + } +} + +impl From for ApiError { + fn from(error: FetchWarrenError) -> Self { + match error { + FetchWarrenError::NotFound(uuid) => { + Self::NotFound(format!("Warren with id {uuid} does not exist")) + } + FetchWarrenError::Unknown(_cause) => { + Self::InternalServerError("Internal server error".to_string()) + } + } + } +} + +#[derive(Debug, Clone, Error)] +enum ParseFetchWarrenHttpRequestError {} + +impl From for ApiError { + fn from(_e: ParseFetchWarrenHttpRequestError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct FetchWarrenHttpRequestBody { + id: Uuid, +} + +impl FetchWarrenHttpRequestBody { + fn try_into_domain(self) -> Result { + Ok(FetchWarrenRequest::new(self.id)) + } +} + +pub async fn fetch_warren( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .warren_service + .fetch_warren(domain_request) + .await + .map(|ref warren| ApiSuccess::new(StatusCode::OK, warren.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs b/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs new file mode 100644 index 0000000..f38a62c --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs @@ -0,0 +1,115 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::{ + file_system::models::file::{ + AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType, + }, + warren::{ + models::warren::{ListWarrenFilesError, ListWarrenFilesRequest}, + ports::WarrenService, + }, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, Error)] +pub enum ParseListWarrenHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListWarrenFilesHttpRequestBody { + warren_id: Uuid, + path: String, +} + +impl ListWarrenFilesHttpRequestBody { + fn try_into_domain(self) -> Result { + let path = FilePath::new(&self.path)?; + + Ok(ListWarrenFilesRequest::new( + self.warren_id, + path.try_into()?, + )) + } +} + +impl From for ApiError { + fn from(value: ParseListWarrenHttpRequestError) -> Self { + match value { + ParseListWarrenHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => { + ApiError::BadRequest("The file path must be valid".to_string()) + } + }, + ParseListWarrenHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + ApiError::BadRequest("The file path must be absolute".to_string()) + } + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WarrenFileElement { + name: String, + file_type: FileType, + mime_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListWarrenFilesResponseData { + files: Vec, +} + +impl From for WarrenFileElement { + fn from(value: File) -> Self { + Self { + name: value.name().to_string(), + file_type: value.file_type().to_owned(), + mime_type: value.mime_type().map(FileMimeType::to_string), + } + } +} + +impl From> for ListWarrenFilesResponseData { + fn from(value: Vec) -> Self { + Self { + files: value.into_iter().map(WarrenFileElement::from).collect(), + } + } +} + +impl From for ApiError { + fn from(_value: ListWarrenFilesError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +pub async fn list_warren_files( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .warren_service + .list_files(domain_request) + .await + .map(|files| ApiSuccess::new(StatusCode::OK, files.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs b/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs new file mode 100644 index 0000000..e259a02 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs @@ -0,0 +1,65 @@ +use axum::{extract::State, http::StatusCode}; +use serde::Serialize; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::warren::{ListWarrensError, ListWarrensRequest, Warren}, + ports::WarrenService, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WarrenListElement { + id: Uuid, + name: String, +} + +impl From<&Warren> for WarrenListElement { + fn from(value: &Warren) -> Self { + Self { + id: *value.id(), + name: value.name().to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ListWarrensResponseData { + warrens: Vec, +} + +impl From<&Vec> for ListWarrensResponseData { + fn from(value: &Vec) -> Self { + ListWarrensResponseData { + warrens: value.into_iter().map(WarrenListElement::from).collect(), + } + } +} + +impl From for ApiError { + fn from(error: ListWarrensError) -> Self { + match error { + ListWarrensError::Unknown(_cause) => { + Self::InternalServerError("Internal server error".to_string()) + } + } + } +} + +pub async fn list_warrens( + State(state): State>, +) -> Result, ApiError> { + let domain_request = ListWarrensRequest::new(); + + state + .warren_service + .list_warrens(domain_request) + .await + .map_err(ApiError::from) + .map(|ref warrens| ApiSuccess::new(StatusCode::OK, warrens.into())) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/mod.rs b/backend/src/lib/inbound/http/handlers/warrens/mod.rs new file mode 100644 index 0000000..936c8da --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/mod.rs @@ -0,0 +1,40 @@ +mod create_warren_directory; +mod delete_warren_directory; +mod delete_warren_file; +mod fetch_warren; +mod list_warren_files; +mod list_warrens; +mod upload_warren_files; + +use axum::{ + Router, + extract::DefaultBodyLimit, + routing::{delete, get, post}, +}; + +use crate::{domain::warren::ports::WarrenService, inbound::http::AppState}; + +use fetch_warren::fetch_warren; +use list_warren_files::list_warren_files; +use list_warrens::list_warrens; + +use create_warren_directory::create_warren_directory; +use delete_warren_directory::delete_warren_directory; + +use delete_warren_file::delete_warren_file; +use upload_warren_files::upload_warren_files; + +pub fn routes() -> Router> { + Router::new() + .route("/", get(list_warrens)) + .route("/", post(fetch_warren)) + .route("/files", post(list_warren_files)) + .route("/files/directory", post(create_warren_directory)) + .route("/files/directory", delete(delete_warren_directory)) + .route( + "/files/upload", + // 1073741824 bytes = 1GB + post(upload_warren_files).route_layer(DefaultBodyLimit::max(1073741824)), + ) + .route("/files/file", delete(delete_warren_file)) +} diff --git a/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs b/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs new file mode 100644 index 0000000..37f1dac --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs @@ -0,0 +1,121 @@ +use axum::{body::Bytes, extract::State, http::StatusCode}; +use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::{ + file_system::models::file::{ + AbsoluteFilePathError, FileName, FileNameError, FilePath, FilePathError, + }, + warren::{ + models::warren::{ + UploadFile, UploadFileList, UploadFileListError, UploadWarrenFilesError, + UploadWarrenFilesRequest, + }, + ports::WarrenService, + }, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, TryFromMultipart)] +#[try_from_multipart(rename_all = "camelCase")] +pub struct UploadWarrenFilesHttpRequestBody { + warren_id: Uuid, + path: String, + files: Vec>, +} + +#[derive(Debug, Clone, Error)] +enum ParseUploadWarrenFilesHttpRequestError { + #[error(transparent)] + FileName(#[from] FileNameError), + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + FileList(#[from] UploadFileListError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), +} + +impl UploadWarrenFilesHttpRequestBody { + fn try_into_domain( + self, + ) -> Result { + let path = FilePath::new(&self.path)?; + + let mut files = Vec::with_capacity(self.files.len()); + + for file in self.files { + let raw_file_name = file.metadata.file_name.ok_or(FileNameError::Empty)?; + let file_name = FileName::new(&raw_file_name)?; + let data = file.contents.to_vec().into_boxed_slice(); + files.push(UploadFile::new(file_name, data)); + } + + let files = UploadFileList::new(files)?; + + Ok(UploadWarrenFilesRequest::new( + self.warren_id, + path.try_into()?, + files, + )) + } +} + +impl From for ApiError { + fn from(value: ParseUploadWarrenFilesHttpRequestError) -> Self { + match value { + ParseUploadWarrenFilesHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => { + ApiError::BadRequest("The file path must be valid".to_string()) + } + }, + ParseUploadWarrenFilesHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + ApiError::BadRequest("The file path must be absolute".to_string()) + } + }, + ParseUploadWarrenFilesHttpRequestError::FileList(err) => match err { + UploadFileListError::Empty => { + ApiError::BadRequest("There has to be at least 1 file present".to_string()) + } + }, + ParseUploadWarrenFilesHttpRequestError::FileName(err) => match err { + FileNameError::Slash => { + ApiError::BadRequest("A file's name contained an invalid character".to_string()) + } + FileNameError::Empty => { + ApiError::BadRequest("File names must not be empty".to_string()) + } + }, + } + } +} + +impl From for ApiError { + fn from(_: UploadWarrenFilesError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +pub async fn upload_warren_files( + State(state): State>, + TypedMultipart(multipart): TypedMultipart, +) -> Result, ApiError> +where + WS: WarrenService, +{ + let domain_request = multipart.try_into_domain()?; + + state + .warren_service + .upload_warren_files(domain_request) + .await + .map(|_| ApiSuccess::new(StatusCode::CREATED, ())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/mod.rs b/backend/src/lib/inbound/http/mod.rs new file mode 100644 index 0000000..995d91d --- /dev/null +++ b/backend/src/lib/inbound/http/mod.rs @@ -0,0 +1,107 @@ +pub mod handlers; +pub mod responses; + +use std::sync::Arc; + +use anyhow::Context; +use axum::{Router, http::HeaderValue}; +use handlers::warrens; +use tokio::net::TcpListener; +use tower_http::{cors::CorsLayer, services::ServeDir}; + +use crate::domain::warren::ports::WarrenService; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpServerConfig<'a> { + pub address: &'a str, + pub port: &'a u16, + + pub cors_allow_origin: &'a str, + + pub static_frontend_dir: Option<&'a str>, +} + +impl<'a> HttpServerConfig<'a> { + pub fn new( + address: &'a str, + port: &'a u16, + cors_allow_origin: &'a str, + static_frontend_dir: Option<&'a str>, + ) -> Self { + Self { + address, + port, + cors_allow_origin, + + static_frontend_dir, + } + } +} + +#[derive(Debug, Clone)] +pub struct AppState { + warren_service: Arc, +} + +pub struct HttpServer { + router: Router, + listener: TcpListener, +} + +impl HttpServer { + pub async fn new( + warren_service: WS, + config: HttpServerConfig<'_>, + ) -> anyhow::Result { + let cors_layer = cors_layer(&config)?; + + let state = AppState { + warren_service: Arc::new(warren_service), + }; + + let mut router = Router::new() + .nest("/api", api_routes()) + .layer(cors_layer) + .with_state(state); + + if let Some(frontend_dir) = config.static_frontend_dir { + let frontend_service = ServeDir::new(frontend_dir); + + log::debug!("Registering static frontend"); + router = router.fallback_service(frontend_service); + } + + let bind_addr = format!("{}:{}", config.address, config.port); + let listener = TcpListener::bind(&bind_addr) + .await + .context(format!("Failed to listen on {bind_addr}"))?; + + Ok(Self { router, listener }) + } + + pub async fn run(self) -> anyhow::Result<()> { + log::info!("Listening on {}", self.listener.local_addr()?); + + axum::serve(self.listener, self.router) + .await + .context("HTTP server error")?; + + Ok(()) + } +} + +fn cors_layer(config: &HttpServerConfig<'_>) -> anyhow::Result { + let origin = HeaderValue::from_str(config.cors_allow_origin) + .context("Failed to convert cors_allow_origin to a header")?; + + let layer = CorsLayer::default() + .allow_origin(origin) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any); + + Ok(layer) +} + +fn api_routes() -> Router> { + Router::new().nest("/warrens", warrens::routes()) +} diff --git a/backend/src/lib/inbound/http/responses.rs b/backend/src/lib/inbound/http/responses.rs new file mode 100644 index 0000000..792fe8c --- /dev/null +++ b/backend/src/lib/inbound/http/responses.rs @@ -0,0 +1,76 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +/// Generic response structure shared by all API responses. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResponseBody { + #[serde(rename = "status")] + status_code: u16, + data: T, +} + +impl ResponseBody { + pub fn new(status: StatusCode, data: T) -> Self { + Self { + status_code: status.as_u16(), + data, + } + } +} + +#[derive(Debug, Clone)] +pub struct ApiSuccess(StatusCode, Json>); + +impl PartialEq for ApiSuccess +where + T: Serialize + PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 && self.1.0 == other.1.0 + } +} + +impl ApiSuccess { + pub fn new(status: StatusCode, data: T) -> Self { + Self(status, Json(ResponseBody::new(status, data))) + } +} + +impl IntoResponse for ApiSuccess { + fn into_response(self) -> Response { + (self.0, self.1).into_response() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApiError { + BadRequest(String), + NotFound(String), + InternalServerError(String), +} + +impl From for ApiError { + fn from(e: anyhow::Error) -> Self { + Self::InternalServerError(e.to_string()) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + use ApiError::*; + + match self { + BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad request".to_string()).into_response(), + NotFound(_) => (StatusCode::NOT_FOUND, "Not found".to_string()).into_response(), + InternalServerError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + .into_response(), + } + } +} diff --git a/backend/src/lib/inbound/mod.rs b/backend/src/lib/inbound/mod.rs new file mode 100644 index 0000000..3883215 --- /dev/null +++ b/backend/src/lib/inbound/mod.rs @@ -0,0 +1 @@ +pub mod http; diff --git a/backend/src/lib/lib.rs b/backend/src/lib/lib.rs index da52c98..cf1cf8c 100644 --- a/backend/src/lib/lib.rs +++ b/backend/src/lib/lib.rs @@ -1,10 +1,5 @@ -use error::AppError; - -pub mod api; -pub mod db; -pub mod error; -pub mod fs; -pub mod server; +pub mod config; +pub mod domain; +pub mod inbound; +pub mod outbound; pub mod warrens; - -pub type Result = std::result::Result; diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs new file mode 100644 index 0000000..ac655bf --- /dev/null +++ b/backend/src/lib/outbound/file_system.rs @@ -0,0 +1,192 @@ +use anyhow::{Context, anyhow}; +use tokio::{fs, io::AsyncWriteExt as _}; + +use crate::domain::file_system::{ + models::file::{ + AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, + CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, + DeleteFileRequest, File, FileMimeType, FileName, FilePath, FileType, ListFilesError, + ListFilesRequest, + }, + ports::FileSystemRepository, +}; + +#[derive(Debug, Clone)] +pub struct FileSystemConfig { + base_directory: String, +} + +impl FileSystemConfig { + pub fn new(base_directory: String) -> Self { + Self { base_directory } + } +} + +#[derive(Debug, Clone)] +pub struct FileSystem { + base_directory: FilePath, +} + +impl FileSystem { + pub fn new(config: FileSystemConfig) -> anyhow::Result { + let file_system = Self { + base_directory: FilePath::new(&config.base_directory)?, + }; + + Ok(file_system) + } + + /// Combines `self.base_directory` with the specified path + /// + /// * `path`: The absolute path (absolute in relation to the base directory) + fn get_target_path(&self, path: &AbsoluteFilePath) -> FilePath { + self.base_directory.join(&path.as_relative()) + } + + async fn get_all_files(&self, absolute_path: &AbsoluteFilePath) -> anyhow::Result> { + let directory_path = self.get_target_path(absolute_path); + + let mut dir = fs::read_dir(&directory_path).await?; + + let mut files = Vec::new(); + + while let Ok(Some(entry)) = dir.next_entry().await { + let name = entry + .file_name() + .into_string() + .ok() + .context("Failed to get file name")?; + let file_type = { + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + FileType::Directory + } else if file_type.is_file() { + FileType::File + } else { + continue; + } + }; + + let mime_type = match file_type { + FileType::File => FileMimeType::from_name(&name), + _ => None, + }; + + files.push(File::new(FileName::new(&name)?, file_type, mime_type)); + } + + Ok(files) + } + + /// Actually created a directory in the underlying file system + /// + /// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`) + async fn create_dir(&self, path: &AbsoluteFilePath) -> anyhow::Result { + let file_path = self.get_target_path(path); + + fs::create_dir(&file_path).await?; + + Ok(file_path) + } + + /// Actually removes a directory from the underlying file system + /// + /// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`) + /// * `force`: Whether to delete directories that are not empty + async fn remove_dir(&self, path: &AbsoluteFilePath, force: bool) -> anyhow::Result { + let file_path = self.get_target_path(path); + + if force { + fs::remove_dir_all(&file_path).await?; + } else { + fs::remove_dir(&file_path).await?; + } + + Ok(file_path) + } + + async fn write_file(&self, path: &AbsoluteFilePath, data: &[u8]) -> anyhow::Result { + let path = self.get_target_path(path); + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .open(&path) + .await?; + + file.write_all(data).await?; + + Ok(path) + } + + /// Actually removes a file from the underlying file system + /// + /// * `path`: The file's absolute path (absolute not in relation to the root file system but `self.base_directory`) + async fn remove_file(&self, path: &AbsoluteFilePath) -> anyhow::Result { + let path = self.get_target_path(path); + + fs::remove_file(&path).await?; + + Ok(path) + } +} + +impl FileSystemRepository for FileSystem { + async fn list_files(&self, request: ListFilesRequest) -> Result, ListFilesError> { + let files = self.get_all_files(request.path()).await.map_err(|err| { + anyhow!(err).context(format!( + "Failed to get the files at path: {}", + request.path() + )) + })?; + + Ok(files) + } + + async fn create_directory( + &self, + request: CreateDirectoryRequest, + ) -> Result { + let created_path = self.create_dir(request.path()).await.context(format!( + "Failed to create directory at path {}", + request.path() + ))?; + + Ok(created_path) + } + + async fn delete_directory( + &self, + request: DeleteDirectoryRequest, + ) -> Result { + let deleted_path = self + .remove_dir(request.path(), false) + .await + .context(format!("Failed to delete directory at {}", request.path()))?; + + Ok(deleted_path) + } + + async fn create_file(&self, request: CreateFileRequest) -> Result { + let file_path = self + .write_file(request.path(), request.data()) + .await + .context(format!( + "Failed to write {} byte(s) to path {}", + request.data().len(), + request.path() + ))?; + + Ok(file_path) + } + + async fn delete_file(&self, request: DeleteFileRequest) -> Result { + let deleted_path = self + .remove_file(request.path()) + .await + .context(format!("Failed to delete file at {}", request.path()))?; + + Ok(deleted_path) + } +} diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs new file mode 100644 index 0000000..51e31f7 --- /dev/null +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -0,0 +1,110 @@ +use crate::domain::{file_system::ports::FileSystemMetrics, warren::ports::WarrenMetrics}; + +#[derive(Debug, Clone, Copy)] +pub struct MetricsDebugLogger; + +impl MetricsDebugLogger { + pub fn new() -> Self { + Self {} + } +} + +impl WarrenMetrics for MetricsDebugLogger { + async fn record_warren_list_success(&self) { + log::debug!("[Metrics] Warren list succeeded"); + } + + async fn record_warren_list_failure(&self) { + log::debug!("[Metrics] Warren list failed"); + } + + async fn record_warren_fetch_success(&self) { + log::debug!("[Metrics] Warren fetch succeeded"); + } + + async fn record_warren_fetch_failure(&self) { + log::debug!("[Metrics] Warren fetch failed"); + } + + async fn record_list_warren_files_success(&self) { + log::debug!("[Metrics] Warren list files succeeded"); + } + + async fn record_list_warren_files_failure(&self) { + log::debug!("[Metrics] Warren list files failed"); + } + + async fn record_warren_directory_creation_success(&self) { + log::debug!("[Metrics] Warren directory creation succeeded"); + } + + async fn record_warren_directory_creation_failure(&self) { + log::debug!("[Metrics] Warren directory creation failed"); + } + + async fn record_warren_directory_deletion_success(&self) { + log::debug!("[Metrics] Warren directory deletion succeeded"); + } + + async fn record_warren_directory_deletion_failure(&self) { + log::debug!("[Metrics] Warren directory deletion failed"); + } + + async fn record_warren_file_upload_success(&self) { + log::debug!("[Metrics] Warren file upload succeeded"); + } + async fn record_warren_file_upload_failure(&self) { + log::debug!("[Metrics] Warren file upload failed"); + } + + async fn record_warren_files_upload_success(&self) { + log::debug!("[Metrics] Warren files upload succeded"); + } + async fn record_warren_files_upload_failure(&self) { + log::debug!("[Metrics] Warren files upload failed at least partially"); + } + + async fn record_warren_file_deletion_success(&self) { + log::debug!("[Metrics] Warren file deletion succeeded"); + } + async fn record_warren_file_deletion_failure(&self) { + log::debug!("[Metrics] Warren file deletion failed"); + } +} + +impl FileSystemMetrics for MetricsDebugLogger { + async fn record_list_files_success(&self) { + log::debug!("[Metrics] File list succeeded"); + } + async fn record_list_files_failure(&self) { + log::debug!("[Metrics] File list failed"); + } + + async fn record_directory_creation_success(&self) { + log::debug!("[Metrics] Directory creation succeeded"); + } + async fn record_directory_creation_failure(&self) { + log::debug!("[Metrics] Directory creation failed"); + } + + async fn record_directory_deletion_success(&self) { + log::debug!("[Metrics] Directory deletion succeeded"); + } + async fn record_directory_deletion_failure(&self) { + log::debug!("[Metrics] Directory deletion failed"); + } + + async fn record_file_creation_success(&self) { + log::debug!("[Metrics] File creation succeeded"); + } + async fn record_file_creation_failure(&self) { + log::debug!("[Metrics] File creation failed"); + } + + async fn record_file_deletion_success(&self) { + log::debug!("[Metrics] File deletion succeeded"); + } + async fn record_file_deletion_failure(&self) { + log::debug!("[Metrics] File deletion failed"); + } +} diff --git a/backend/src/lib/outbound/mod.rs b/backend/src/lib/outbound/mod.rs new file mode 100644 index 0000000..14c3109 --- /dev/null +++ b/backend/src/lib/outbound/mod.rs @@ -0,0 +1,4 @@ +pub mod file_system; +pub mod metrics_debug_logger; +pub mod notifier_debug_logger; +pub mod postgres; diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs new file mode 100644 index 0000000..6dea0f6 --- /dev/null +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -0,0 +1,96 @@ +use crate::domain::{ + file_system::{ + models::file::{File, FilePath}, + ports::FileSystemNotifier, + }, + warren::{models::warren::Warren, ports::WarrenNotifier}, +}; + +#[derive(Debug, Clone, Copy)] +pub struct NotifierDebugLogger; + +impl NotifierDebugLogger { + pub fn new() -> Self { + Self {} + } +} + +impl WarrenNotifier for NotifierDebugLogger { + async fn warrens_listed(&self, warrens: &Vec) { + log::debug!("[Notifier] Listed {} warren(s)", warrens.len()); + } + + async fn warren_fetched(&self, warren: &Warren) { + log::debug!("[Notifier] Fetched warren {}", warren.name()); + } + + async fn warren_files_listed(&self, warren: &Warren, files: &Vec) { + log::debug!( + "[Notifier] Listed {} file(s) in warren {}", + files.len(), + warren.name() + ); + } + + async fn warren_directory_created(&self, warren: &Warren, path: &FilePath) { + log::debug!( + "[Notifier] Created directory {} in warren {}", + path, + warren.name() + ); + } + + async fn warren_directory_deleted(&self, warren: &Warren, path: &FilePath) { + log::debug!( + "[Notifier] Deleted directory {} in warren {}", + path, + warren.name() + ); + } + + async fn warren_file_uploaded(&self, warren: &Warren, path: &FilePath) { + log::debug!( + "[Notifier] Uploaded file {} to warren {}", + path, + warren.name() + ); + } + + async fn warren_files_uploaded(&self, warren: &Warren, paths: &[FilePath]) { + log::debug!( + "[Notifier] Uploaded {} file(s) to warren {}", + paths.len(), + warren.name() + ); + } + + async fn warren_file_deleted(&self, warren: &Warren, path: &FilePath) { + log::debug!( + "[Notifier] Deleted file {} from warren {}", + path, + warren.name(), + ); + } +} + +impl FileSystemNotifier for NotifierDebugLogger { + async fn files_listed(&self, files: &Vec) { + log::debug!("[Notifier] Listed {} file(s)", files.len()); + } + + async fn directory_created(&self, path: &FilePath) { + log::debug!("[Notifier] Created directory {}", path); + } + + async fn directory_deleted(&self, path: &FilePath) { + log::debug!("[Notifier] Deleted directory {}", path); + } + + async fn file_created(&self, path: &FilePath) { + log::debug!("[Notifier] Created file {}", path); + } + + async fn file_deleted(&self, path: &FilePath) { + log::debug!("[Notifier] Deleted file {}", path); + } +} diff --git a/backend/src/lib/outbound/postgres.rs b/backend/src/lib/outbound/postgres.rs new file mode 100644 index 0000000..afcd6e7 --- /dev/null +++ b/backend/src/lib/outbound/postgres.rs @@ -0,0 +1,148 @@ +use std::str::FromStr; + +use anyhow::{Context, anyhow}; +use sqlx::{ + ConnectOptions as _, Connection as _, PgConnection, PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; +use uuid::Uuid; + +use crate::domain::warren::{ + models::warren::{ + FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren, + }, + ports::WarrenRepository, +}; + +#[derive(Debug, Clone)] +pub struct PostgresConfig { + database_url: String, + database_name: String, +} + +impl PostgresConfig { + pub fn new(database_url: String, database_name: String) -> Self { + Self { + database_url, + database_name, + } + } +} + +#[derive(Debug, Clone)] +pub struct Postgres { + pool: PgPool, +} + +impl Postgres { + pub async fn new(config: PostgresConfig) -> anyhow::Result { + let opts = PgConnectOptions::from_str(&config.database_url)?.disable_statement_logging(); + + let mut connection = PgConnection::connect_with(&opts) + .await + .context("Failed to connect to the PostgreSQL database")?; + + // If this fails it's probably because the database already exists, which is exactly what + // we want + let _ = sqlx::query(&format!("CREATE DATABASE {}", config.database_name)) + .execute(&mut connection) + .await; + connection.close().await?; + + let pool = PgPoolOptions::new() + .connect_with(opts.database(&config.database_name)) + .await?; + sqlx::migrate!("./migrations").run(&pool).await?; + + Ok(Self { pool }) + } + + async fn get_warren( + &self, + connection: &mut PgConnection, + id: &Uuid, + ) -> Result { + let warren: Warren = sqlx::query_as( + " + SELECT + * + FROM + warrens + WHERE + id = $1 + ", + ) + .bind(id) + .fetch_one(connection) + .await?; + + Ok(warren) + } + + async fn list_warrens( + &self, + connection: &mut PgConnection, + ) -> Result, sqlx::Error> { + let warrens: Vec = sqlx::query_as::( + " + SELECT + * + FROM + warrens + LIMIT + 50 + ", + ) + .fetch_all(&mut *connection) + .await?; + + Ok(warrens) + } +} + +impl WarrenRepository for Postgres { + async fn list_warrens( + &self, + _request: ListWarrensRequest, + ) -> Result, ListWarrensError> { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let warrens = self + .list_warrens(&mut connection) + .await + .map_err(|err| anyhow!(err).context("Failed to fetch warren with id"))?; + + Ok(warrens) + } + + async fn fetch_warren(&self, request: FetchWarrenRequest) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let warren = self + .get_warren(&mut connection, request.id()) + .await + .map_err(|err| { + if is_not_found_error(&err) { + return FetchWarrenError::NotFound(request.id().clone()); + } + + anyhow!(err) + .context(format!("Failed to fetch warren with id {:?}", request.id())) + .into() + })?; + + Ok(warren) + } +} + +fn is_not_found_error(err: &sqlx::Error) -> bool { + matches!(err, sqlx::Error::RowNotFound) +} diff --git a/backend/src/lib/server.rs b/backend/src/lib/server.rs deleted file mode 100644 index e29ad7f..0000000 --- a/backend/src/lib/server.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::env; - -use axum::http::HeaderValue; -use sqlx::{Pool, Postgres}; -use tokio::net::TcpListener; -use tower_http::{ - cors::{self, CorsLayer}, - services::ServeDir, -}; - -use crate::{ - Result, - api::{self, AppState}, -}; - -pub type Router = axum::Router; - -pub async fn start(pool: Pool) -> Result<()> { - let cors_origin = env::var("CORS_ALLOW_ORIGIN").unwrap_or("*".to_string()); - let serve_dir = std::env::var("SERVE_DIRECTORY")?; - - let mut app = Router::new() - .nest("/api", api::router()) - .layer( - CorsLayer::new() - .allow_origin(cors::AllowOrigin::exact(HeaderValue::from_str( - &cors_origin, - )?)) - .allow_methods(cors::Any), - ) - .with_state(AppState::new(pool, serve_dir)); - - if !cfg!(debug_assertions) { - let frontend_path = env::var("STATIC_FRONTEND_DIR").unwrap_or("./frontend".to_string()); - let frontend_service = ServeDir::new(frontend_path); - - log::debug!("Registering static frontend"); - app = app.fallback_service(frontend_service); - } - - let addr = env::var("BIND_ADDRESS").unwrap_or("127.0.0.1:8080".to_string()); - - let listener = TcpListener::bind(&addr).await?; - - log::info!("Listening on {}", addr); - - axum::serve(listener, app).await?; - - Ok(()) -} diff --git a/backend/src/lib/warrens/mod.rs b/backend/src/lib/warrens/mod.rs index 415ea7a..8f0109b 100644 --- a/backend/src/lib/warrens/mod.rs +++ b/backend/src/lib/warrens/mod.rs @@ -1,4 +1,4 @@ -pub mod db; +/* pub mod db; use std::path::PathBuf; @@ -92,4 +92,4 @@ fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> P } final_path -} +} */ diff --git a/frontend/components/DirectoryEntry.vue b/frontend/components/DirectoryEntry.vue index d99e0aa..fd51771 100644 --- a/frontend/components/DirectoryEntry.vue +++ b/frontend/components/DirectoryEntry.vue @@ -5,7 +5,7 @@ import { ContextMenuContent, ContextMenuItem, } from '@/components/ui/context-menu'; -import { deleteWarrenEntry } from '~/lib/api/warrens'; +import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens'; import type { DirectoryEntry } from '~/types'; const route = useRoute(); @@ -21,7 +21,11 @@ const deleting = ref(false); async function submitDelete() { deleting.value = true; - await deleteWarrenEntry(warrenRoute.value, entry.name, entry.fileType); + if (entry.fileType === 'directory') { + await deleteWarrenDirectory(warrenRoute.value, entry.name); + } else { + await deleteWarrenFile(warrenRoute.value, entry.name); + } deleting.value = false; } @@ -32,7 +36,13 @@ async function submitDelete() {